From 0e7b102c9da0d5ca5dc8921c1a3e17c4558e2415 Mon Sep 17 00:00:00 2001 From: demonkiller Date: Mon, 12 Jan 2026 21:33:23 +0000 Subject: [PATCH 01/20] update to latest standard --- package-lock.json | 1073 ++++++++++++++++++++++++++------------------- package.json | 28 +- 2 files changed, 646 insertions(+), 455 deletions(-) diff --git a/package-lock.json b/package-lock.json index e2e8e1c..3a348d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,31 +1,54 @@ { - "name": "tchat", - "version": "1.0.0", + "name": "oschat", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "tchat", - "version": "1.0.0", - "license": "ISC", + "name": "oschat", + "version": "2.0.0", + "license": "MIT", "dependencies": { - "express": "^4.18.2", - "socket.io": "^2.5.0" + "express": "^4.21.2", + "socket.io": "^4.8.1" }, "devDependencies": { - "nodemon": "^2.0.22" + "nodemon": "^3.1.9" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=9.0.0" } }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true + "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/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/node": { + "version": "25.0.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.6.tgz", + "integrity": "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } }, "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" @@ -34,16 +57,12 @@ "node": ">= 0.6" } }, - "node_modules/after": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz", - "integrity": "sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA==" - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, + "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -55,71 +74,56 @@ "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, - "node_modules/arraybuffer.slice": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz", - "integrity": "sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==" - }, - "node_modules/backo2": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", - "integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==" + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/base64-arraybuffer": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz", - "integrity": "sha512-a1eIFi4R9ySrbiMuyTGx5e92uRH5tQY6kArNcFaKBUleIoLjdjBg7Zxm3Mqm3Kmkf27HLR/1fnxX9q8GQ7Iavg==", - "engines": { - "node": ">= 0.6.0" - } + "dev": true, + "license": "MIT" }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", "engines": { "node": "^4.5.0 || >= 5.9" } }, "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/blob": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz", - "integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==" - }, "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.4", + "bytes": "~3.1.2", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", @@ -127,22 +131,24 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -152,33 +158,46 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, - "node_modules/call-bind": { + "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], + "license": "MIT", "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -191,35 +210,25 @@ "engines": { "node": ">= 8.10.0" }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, "optionalDependencies": { "fsevents": "~2.3.2" } }, - "node_modules/component-bind": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz", - "integrity": "sha512-WZveuKPeKAG9qY+FkYDeADzdHyTYdIboXS59ixDeRJL5ZhxpqUnxSOwop4FQjMsiYm3/Or8cegVbpAHNA7pHxw==" - }, - "node_modules/component-emitter": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" - }, - "node_modules/component-inherit": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz", - "integrity": "sha512-w+LhYREhatpVqTESyGFg3NlP6Iu0kEKUHETY9GoZP/pQyW4mHFZuFWRUCIqVPZ36ueVLtoOEZaAqbCF2RDndaA==" - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" }, @@ -231,27 +240,44 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -260,6 +286,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -268,159 +295,190 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/engine.io": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-3.6.1.tgz", - "integrity": "sha512-dfs8EVg/i7QjFsXxn7cCRQ+Wai1G1TlEvHhdYEi80fxn5R1vZ2K661O6v/rezj1FP234SZ14r9CmJke99iYDGg==", + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", + "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", + "license": "MIT", "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", - "cookie": "~0.4.1", - "debug": "~4.1.0", - "engine.io-parser": "~2.2.0", - "ws": "~7.4.2" + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" }, "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/engine.io-client": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.5.3.tgz", - "integrity": "sha512-qsgyc/CEhJ6cgMUwxRRtOndGVhIu5hpL5tR4umSpmX/MvkFoIxUTM7oFMDQumHNzlNLwSVy6qhstFPoWTf7dOw==", - "dependencies": { - "component-emitter": "~1.3.0", - "component-inherit": "0.0.3", - "debug": "~3.1.0", - "engine.io-parser": "~2.2.0", - "has-cors": "1.1.0", - "indexof": "0.0.1", - "parseqs": "0.0.6", - "parseuri": "0.0.6", - "ws": "~7.4.2", - "xmlhttprequest-ssl": "~1.6.2", - "yeast": "0.1.2" - } - }, - "node_modules/engine.io-client/node_modules/debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dependencies": { - "ms": "2.0.0" + "node": ">=10.2.0" } }, "node_modules/engine.io-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-2.2.1.tgz", - "integrity": "sha512-x+dN/fBH8Ro8TFwJ+rkB2AmuVw9Yu2mockR/p3W8f8YtExwFgDvBDi0GWyb4ZLkpahtDGZgtr3zLovanJghPqg==", - "dependencies": { - "after": "0.8.2", - "arraybuffer.slice": "~0.0.7", - "base64-arraybuffer": "0.1.4", - "blob": "0.0.5", - "has-binary2": "~1.0.2" - } - }, - "node_modules/engine.io/node_modules/cookie": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", - "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "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": ">= 0.6" + "node": ">=10.0.0" } }, "node_modules/engine.io/node_modules/debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", - "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "^2.1.1" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/engine.io/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -429,16 +487,17 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "statuses": "2.0.1", + "statuses": "~2.0.2", "unpipe": "~1.0.0" }, "engines": { @@ -449,6 +508,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -457,6 +517,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -467,6 +528,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -476,29 +538,57 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, + "license": "ISC", "dependencies": { "is-glob": "^4.0.1" }, @@ -506,40 +596,33 @@ "node": ">= 6" } }, - "node_modules/has": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz", - "integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==", + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-binary2": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-binary2/-/has-binary2-1.0.3.tgz", - "integrity": "sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==", - "dependencies": { - "isarray": "2.0.1" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-cors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz", - "integrity": "sha512-g5VNKdkFuUuVCP9gYfDJHjK2nqdQJ7aDLTnycnc2+RvsOQbuLdF5pm7vuE5J76SEBIQjs4kQY/BWq74JUmjbXA==" - }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, - "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -547,36 +630,43 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -588,22 +678,20 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true - }, - "node_modules/indexof": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", - "integrity": "sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==" + "dev": true, + "license": "ISC" }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", "engines": { "node": ">= 0.10" } @@ -613,6 +701,7 @@ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, + "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" }, @@ -625,6 +714,7 @@ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -634,6 +724,7 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, + "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -646,32 +737,43 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } }, - "node_modules/isarray": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz", - "integrity": "sha512-c2cu3UxbI+b6kR3fy0nRnAhodsvR9dx7U5+znCOzdj6IfP3upFURTr0Xl5BlQZNKZjEtxrmVyfSdeE3O57smoQ==" + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -680,6 +782,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", "bin": { "mime": "cli.js" }, @@ -691,6 +794,7 @@ "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" } @@ -699,6 +803,7 @@ "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" }, @@ -711,6 +816,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -721,29 +827,32 @@ "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" }, "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/nodemon": { - "version": "2.0.22", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", - "integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", + "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", "dev": true, + "license": "MIT", "dependencies": { "chokidar": "^3.5.2", - "debug": "^3.2.7", + "debug": "^4", "ignore-by-default": "^1.0.1", "minimatch": "^3.1.2", "pstree.remy": "^1.1.8", - "semver": "^5.7.1", - "simple-update-notifier": "^1.0.7", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" @@ -752,7 +861,7 @@ "nodemon": "bin/nodemon.js" }, "engines": { - "node": ">=8.10.0" + "node": ">=10" }, "funding": { "type": "opencollective", @@ -760,48 +869,57 @@ } }, "node_modules/nodemon/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "^2.1.1" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/nodemon/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/nopt": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", - "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", "dev": true, - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "*" - } + "license": "MIT" }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -810,6 +928,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, @@ -817,34 +936,27 @@ "node": ">= 0.8" } }, - "node_modules/parseqs": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.6.tgz", - "integrity": "sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==" - }, - "node_modules/parseuri": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.6.tgz", - "integrity": "sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==" - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -856,6 +968,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -868,14 +981,16 @@ "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -888,19 +1003,21 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" @@ -911,6 +1028,7 @@ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, + "license": "MIT", "dependencies": { "picomatch": "^2.2.1" }, @@ -935,40 +1053,47 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" }, "node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, + "license": "ISC", "bin": { - "semver": "bin/semver" + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "range-parser": "~1.2.1", - "statuses": "2.0.1" + "statuses": "~2.0.2" }, "engines": { "node": ">= 0.8.0" @@ -977,17 +1102,19 @@ "node_modules/send/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "~0.19.1" }, "engines": { "node": ">= 0.8.0" @@ -996,146 +1123,209 @@ "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simple-update-notifier": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", - "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", - "dev": true, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", "dependencies": { - "semver": "~7.0.0" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" }, "engines": { - "node": ">=8.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", - "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", "dev": true, - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" } }, "node_modules/socket.io": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-2.5.0.tgz", - "integrity": "sha512-gGunfS0od3VpwDBpGwVkzSZx6Aqo9uOcf1afJj2cKnKFAoyl16fvhpsUhmUFd4Ldbvl5JvRQed6eQw6oQp6n8w==", + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", "dependencies": { - "debug": "~4.1.0", - "engine.io": "~3.6.0", - "has-binary2": "~1.0.2", - "socket.io-adapter": "~1.1.0", - "socket.io-client": "2.5.0", - "socket.io-parser": "~3.4.0" + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "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": "1.1.2", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz", - "integrity": "sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g==" - }, - "node_modules/socket.io-client": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-2.5.0.tgz", - "integrity": "sha512-lOO9clmdgssDykiOmVQQitwBAF3I6mYcQAo7hQ7AM6Ny5X7fp8hIJ3HcQs3Rjz4SoggoxA1OgrQyY8EgTbcPYw==", - "dependencies": { - "backo2": "1.0.2", - "component-bind": "1.0.0", - "component-emitter": "~1.3.0", - "debug": "~3.1.0", - "engine.io-client": "~3.5.0", - "has-binary2": "~1.0.2", - "indexof": "0.0.1", - "parseqs": "0.0.6", - "parseuri": "0.0.6", - "socket.io-parser": "~3.3.0", - "to-array": "0.1.4" - } - }, - "node_modules/socket.io-client/node_modules/debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", "dependencies": { - "ms": "2.0.0" + "debug": "~4.4.1", + "ws": "~8.18.3" } }, - "node_modules/socket.io-client/node_modules/socket.io-parser": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.3.3.tgz", - "integrity": "sha512-qOg87q1PMWWTeO01768Yh9ogn7chB9zkKtQnya41Y355S0UmpXgpcrFwAgjYJxu9BdKug5r5e9YtVSeWhKBUZg==", + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "component-emitter": "~1.3.0", - "debug": "~3.1.0", - "isarray": "2.0.1" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/socket.io-parser": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.4.3.tgz", - "integrity": "sha512-1rE4dZN3kCI/E5wixd393hmbqa78vVpkKmnEJhLeWoS/C5hbFYAbcSfnWoaVH43u9ToUVtzKjguxEZq+1XZfCQ==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", "dependencies": { - "component-emitter": "1.2.1", - "debug": "~4.1.0", - "isarray": "2.0.1" + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" }, "engines": { "node": ">=10.0.0" } }, - "node_modules/socket.io-parser/node_modules/component-emitter": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", - "integrity": "sha512-jPatnhd33viNplKjqXKRkGU345p263OIWzDL2wH3LGIGp5Kojo+uXizHmOADRvhGFFTnJqX3jBAKP6vvmSDKcA==" - }, "node_modules/socket.io-parser/node_modules/debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", - "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "^2.1.1" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/socket.io-parser/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/socket.io/node_modules/debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", - "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "^2.1.1" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/socket.io/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -1145,6 +1335,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -1152,16 +1343,12 @@ "node": ">=4" } }, - "node_modules/to-array": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz", - "integrity": "sha512-LhVdShQD/4Mk4zXNroIQZJC+Ap3zgLcDuwEdcmLv9CCO73NWockQDwyUnW/m8VX/EElfL6FcYx7EeutN4HJA6A==" - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -1173,18 +1360,17 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", "engines": { "node": ">=0.6" } }, "node_modules/touch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", - "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", "dev": true, - "dependencies": { - "nopt": "~1.0.10" - }, + "license": "ISC", "bin": { "nodetouch": "bin/nodetouch.js" } @@ -1193,6 +1379,7 @@ "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -1205,12 +1392,20 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -1219,6 +1414,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", "engines": { "node": ">= 0.4.0" } @@ -1227,20 +1423,22 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/ws": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", - "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -1250,19 +1448,6 @@ "optional": true } } - }, - "node_modules/xmlhttprequest-ssl": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.6.3.tgz", - "integrity": "sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/yeast": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", - "integrity": "sha512-8HFIh676uyGYP6wP13R/j6OJ/1HwJ46snpvzE7aHAN3Ryqh2yX6Xox2B4CUmTwwOIzlG3Bs7ocsP5dZH/R1Qbg==" } } } diff --git a/package.json b/package.json index fdcd998..81a77fc 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,26 @@ { - "name": "tchat", - "version": "1.0.0", - "description": "", - "main": "index.js", + "name": "oschat", + "version": "2.0.0", + "description": "Real-time chat application", + "main": "server.js", + "type": "commonjs", "scripts": { - "dev": "nodemon server", - "serve": "node server" + "start": "node server.js", + "dev": "nodemon server.js", + "serve": "node server.js" }, - "keywords": [], + "keywords": ["chat", "socket.io", "real-time"], "author": "demonkiller", - "license": "ISC", + "license": "MIT", "dependencies": { - "express": "^4.18.2", - "socket.io": "^2.5.0" + "express": "^4.21.2", + "socket.io": "^4.8.1" }, "devDependencies": { - "nodemon": "^2.0.22" + "nodemon": "^3.1.9" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=9.0.0" } } From 3c3ebd88969c4592956b137692a73947bdee705e Mon Sep 17 00:00:00 2001 From: demonkiller Date: Mon, 12 Jan 2026 22:28:45 +0000 Subject: [PATCH 02/20] remove redundant script --- git_commit.sh | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100755 git_commit.sh diff --git a/git_commit.sh b/git_commit.sh deleted file mode 100755 index f98666b..0000000 --- a/git_commit.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash -#set -e -# change a commit comment -# git commit --amend -m "more info" -# git push --force origin - - -# checking if I have the latest files from github -echo "Checking for newer files online first" -git pull - -# Below command will backup everything inside the project folder -git add --all . - -# Give a comment to the commit if you want -echo "####################################" -echo "Write your commit comment!" -echo "####################################" - -read input - -# Committing to the local repository with a message containing the time details and commit text - -git commit -m "$input" - -# Push the local files to github - -git push - - -echo "################################################################" -echo "################### Git Push Done ######################" -echo "################################################################" From 19e0215e55dd3c66e3d94103189ec77996aa066d Mon Sep 17 00:00:00 2001 From: demonkiller Date: Tue, 13 Jan 2026 14:24:04 +0000 Subject: [PATCH 03/20] update frontend/backend --- apps/server/.dockerignore | 7 + apps/server/.env.example | 19 + apps/server/.eslintrc.cjs | 22 + apps/server/.gitignore | 33 ++ apps/server/Dockerfile | 45 ++ apps/server/package.json | 42 ++ .../20260112000000_init/migration.sql | 87 ++++ apps/server/prisma/schema.prisma | 90 ++++ apps/server/src/__tests__/basic.test.ts | 13 + apps/server/src/config/env.ts | 24 + apps/server/src/config/index.ts | 1 + apps/server/src/index.ts | 84 ++++ apps/server/src/lib/index.ts | 1 + apps/server/src/lib/prisma.ts | 17 + apps/server/src/middleware/auth.ts | 18 + apps/server/src/middleware/index.ts | 1 + apps/server/src/routes/auth.ts | 136 ++++++ apps/server/src/routes/conversations.ts | 444 ++++++++++++++++++ apps/server/src/routes/index.ts | 3 + apps/server/src/routes/users.ts | 58 +++ apps/server/src/schemas/index.ts | 91 ++++ apps/server/src/socket/index.ts | 313 ++++++++++++ apps/server/src/types/fastify.ts | 31 ++ apps/server/tsconfig.json | 24 + apps/server/vitest.config.ts | 13 + apps/web/.dockerignore | 4 + apps/web/.env.example | 7 + apps/web/.eslintrc.json | 3 + apps/web/.gitignore | 29 ++ apps/web/Dockerfile | 51 ++ apps/web/next-env.d.ts | 5 + apps/web/next.config.mjs | 20 + apps/web/package.json | 35 ++ apps/web/postcss.config.js | 6 + apps/web/src/app/auth/callback/page.tsx | 53 +++ apps/web/src/app/chat/page.tsx | 99 ++++ apps/web/src/app/globals.css | 49 ++ apps/web/src/app/layout.tsx | 22 + apps/web/src/app/page.tsx | 128 +++++ .../src/components/chat/ConversationList.tsx | 327 +++++++++++++ apps/web/src/components/chat/MessageArea.tsx | 212 +++++++++ .../src/components/chat/SocketProvider.tsx | 69 +++ apps/web/src/components/chat/index.ts | 3 + apps/web/src/components/ui/Avatar.tsx | 46 ++ apps/web/src/components/ui/Spinner.tsx | 23 + apps/web/src/components/ui/index.ts | 2 + apps/web/src/lib/api.ts | 64 +++ apps/web/src/lib/index.ts | 3 + apps/web/src/lib/socket.ts | 176 +++++++ apps/web/src/lib/utils.ts | 49 ++ apps/web/src/stores/auth.ts | 85 ++++ apps/web/src/stores/conversations.ts | 133 ++++++ apps/web/src/stores/index.ts | 3 + apps/web/src/stores/messages.ts | 273 +++++++++++ apps/web/src/types/index.ts | 72 +++ apps/web/tailwind.config.js | 28 ++ apps/web/tsconfig.json | 26 + 57 files changed, 3722 insertions(+) create mode 100644 apps/server/.dockerignore create mode 100644 apps/server/.env.example create mode 100644 apps/server/.eslintrc.cjs create mode 100644 apps/server/.gitignore create mode 100644 apps/server/Dockerfile create mode 100644 apps/server/package.json create mode 100644 apps/server/prisma/migrations/20260112000000_init/migration.sql create mode 100644 apps/server/prisma/schema.prisma create mode 100644 apps/server/src/__tests__/basic.test.ts create mode 100644 apps/server/src/config/env.ts create mode 100644 apps/server/src/config/index.ts create mode 100644 apps/server/src/index.ts create mode 100644 apps/server/src/lib/index.ts create mode 100644 apps/server/src/lib/prisma.ts create mode 100644 apps/server/src/middleware/auth.ts create mode 100644 apps/server/src/middleware/index.ts create mode 100644 apps/server/src/routes/auth.ts create mode 100644 apps/server/src/routes/conversations.ts create mode 100644 apps/server/src/routes/index.ts create mode 100644 apps/server/src/routes/users.ts create mode 100644 apps/server/src/schemas/index.ts create mode 100644 apps/server/src/socket/index.ts create mode 100644 apps/server/src/types/fastify.ts create mode 100644 apps/server/tsconfig.json create mode 100644 apps/server/vitest.config.ts create mode 100644 apps/web/.dockerignore create mode 100644 apps/web/.env.example create mode 100644 apps/web/.eslintrc.json create mode 100644 apps/web/.gitignore create mode 100644 apps/web/Dockerfile create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.mjs create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.js create mode 100644 apps/web/src/app/auth/callback/page.tsx create mode 100644 apps/web/src/app/chat/page.tsx create mode 100644 apps/web/src/app/globals.css create mode 100644 apps/web/src/app/layout.tsx create mode 100644 apps/web/src/app/page.tsx create mode 100644 apps/web/src/components/chat/ConversationList.tsx create mode 100644 apps/web/src/components/chat/MessageArea.tsx create mode 100644 apps/web/src/components/chat/SocketProvider.tsx create mode 100644 apps/web/src/components/chat/index.ts create mode 100644 apps/web/src/components/ui/Avatar.tsx create mode 100644 apps/web/src/components/ui/Spinner.tsx create mode 100644 apps/web/src/components/ui/index.ts create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/index.ts create mode 100644 apps/web/src/lib/socket.ts create mode 100644 apps/web/src/lib/utils.ts create mode 100644 apps/web/src/stores/auth.ts create mode 100644 apps/web/src/stores/conversations.ts create mode 100644 apps/web/src/stores/index.ts create mode 100644 apps/web/src/stores/messages.ts create mode 100644 apps/web/src/types/index.ts create mode 100644 apps/web/tailwind.config.js create mode 100644 apps/web/tsconfig.json diff --git a/apps/server/.dockerignore b/apps/server/.dockerignore new file mode 100644 index 0000000..828c06d --- /dev/null +++ b/apps/server/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.env +.env.local +coverage +.next +*.log diff --git a/apps/server/.env.example b/apps/server/.env.example new file mode 100644 index 0000000..eb9802a --- /dev/null +++ b/apps/server/.env.example @@ -0,0 +1,19 @@ +# Server Configuration +PORT=4000 +HOST=0.0.0.0 +NODE_ENV=development + +# Database (PostgreSQL) +DATABASE_URL=postgresql://oschat:oschat@localhost:5432/oschat?schema=public + +# JWT Secret (Change in production!) +JWT_SECRET=your-super-secret-jwt-key-change-this-in-production + +# Google OAuth +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +GOOGLE_CALLBACK_URL=http://localhost:4000/auth/google/callback + +# Frontend URL (for CORS and redirects) +FRONTEND_URL=http://localhost:3000 +GOOGLE_CALLBACK_URL=http://localhost:4000/api/auth/google/callback diff --git a/apps/server/.eslintrc.cjs b/apps/server/.eslintrc.cjs new file mode 100644 index 0000000..cf6090f --- /dev/null +++ b/apps/server/.eslintrc.cjs @@ -0,0 +1,22 @@ +module.exports = { + root: true, + env: { + node: true, + es2022: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: ['@typescript-eslint'], + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + }, +}; diff --git a/apps/server/.gitignore b/apps/server/.gitignore new file mode 100644 index 0000000..623a265 --- /dev/null +++ b/apps/server/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build outputs +dist/ +.next/ +out/ +build/ + +# Environment files +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Logs +logs/ +*.log +npm-debug.log* + +# Testing +coverage/ + +# Misc +*.tgz +.cache/ diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile new file mode 100644 index 0000000..d4a9301 --- /dev/null +++ b/apps/server/Dockerfile @@ -0,0 +1,45 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Generate Prisma client +RUN npx prisma generate + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 fastify + +# Copy built files +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/prisma ./prisma + +# Set ownership +RUN chown -R fastify:nodejs /app + +USER fastify + +EXPOSE 4000 + +CMD ["npm", "start"] diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..e1f2946 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,42 @@ +{ + "name": "@oschat/server", + "version": "1.0.0", + "description": "OSChat Backend - Fastify + Socket.IO", + "main": "dist/index.js", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:push": "prisma db push", + "db:studio": "prisma studio" + }, + "dependencies": { + "@fastify/cookie": "^9.3.1", + "@fastify/cors": "^9.0.1", + "@fastify/jwt": "^8.0.1", + "@fastify/oauth2": "^7.8.1", + "@fastify/websocket": "^10.0.1", + "@prisma/client": "^5.19.0", + "dotenv": "^16.4.5", + "fastify": "^4.28.1", + "socket.io": "^4.7.5", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^20.14.10", + "eslint": "^8.57.0", + "@typescript-eslint/eslint-plugin": "^7.16.0", + "@typescript-eslint/parser": "^7.16.0", + "prisma": "^5.19.0", + "tsx": "^4.16.2", + "typescript": "^5.5.3", + "vitest": "^2.0.3" + } +} diff --git a/apps/server/prisma/migrations/20260112000000_init/migration.sql b/apps/server/prisma/migrations/20260112000000_init/migration.sql new file mode 100644 index 0000000..71d1fd6 --- /dev/null +++ b/apps/server/prisma/migrations/20260112000000_init/migration.sql @@ -0,0 +1,87 @@ +-- CreateEnum +CREATE TYPE "ConversationType" AS ENUM ('dm', 'group'); + +-- CreateEnum +CREATE TYPE "MemberRole" AS ENUM ('owner', 'admin', 'member'); + +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "google_sub" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL, + "avatar_url" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "conversations" ( + "id" TEXT NOT NULL, + "type" "ConversationType" NOT NULL, + "title" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "conversations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "conversation_members" ( + "id" TEXT NOT NULL, + "conversation_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "role" "MemberRole" NOT NULL DEFAULT 'member', + "joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_seen_at" TIMESTAMP(3), + "last_seen_msg_id" TEXT, + + CONSTRAINT "conversation_members_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "messages" ( + "id" TEXT NOT NULL, + "conversation_id" TEXT NOT NULL, + "sender_id" TEXT NOT NULL, + "client_msg_id" TEXT NOT NULL, + "body" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "messages_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_google_sub_key" ON "users"("google_sub"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE INDEX "conversation_members_user_id_idx" ON "conversation_members"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "conversation_members_conversation_id_user_id_key" ON "conversation_members"("conversation_id", "user_id"); + +-- CreateIndex +CREATE INDEX "messages_conversation_id_created_at_idx" ON "messages"("conversation_id", "created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "messages_sender_id_client_msg_id_key" ON "messages"("sender_id", "client_msg_id"); + +-- AddForeignKey +ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_last_seen_msg_id_fkey" FOREIGN KEY ("last_seen_msg_id") REFERENCES "messages"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma new file mode 100644 index 0000000..2decab4 --- /dev/null +++ b/apps/server/prisma/schema.prisma @@ -0,0 +1,90 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(uuid()) + googleSub String @unique @map("google_sub") + email String @unique + name String + avatarUrl String? @map("avatar_url") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + memberships ConversationMember[] + messages Message[] + + @@map("users") +} + +enum ConversationType { + dm + group +} + +model Conversation { + id String @id @default(uuid()) + type ConversationType + title String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + members ConversationMember[] + messages Message[] + + @@map("conversations") +} + +enum MemberRole { + owner + admin + member +} + +model ConversationMember { + id String @id @default(uuid()) + conversationId String @map("conversation_id") + userId String @map("user_id") + role MemberRole @default(member) + joinedAt DateTime @default(now()) @map("joined_at") + lastSeenAt DateTime? @map("last_seen_at") + lastSeenMsgId String? @map("last_seen_msg_id") + + // Relations + conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + lastSeenMsg Message? @relation("LastSeenMessage", fields: [lastSeenMsgId], references: [id]) + + @@unique([conversationId, userId]) + @@index([userId]) + @@map("conversation_members") +} + +model Message { + id String @id @default(uuid()) + conversationId String @map("conversation_id") + senderId String @map("sender_id") + clientMsgId String @map("client_msg_id") + body String + createdAt DateTime @default(now()) @map("created_at") + + // Relations + conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + sender User @relation(fields: [senderId], references: [id], onDelete: Cascade) + seenBy ConversationMember[] @relation("LastSeenMessage") + + // Unique constraint for idempotency + @@unique([senderId, clientMsgId]) + @@index([conversationId, createdAt]) + @@map("messages") +} diff --git a/apps/server/src/__tests__/basic.test.ts b/apps/server/src/__tests__/basic.test.ts new file mode 100644 index 0000000..625df13 --- /dev/null +++ b/apps/server/src/__tests__/basic.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from 'vitest'; + +describe('Server', () => { + it('should pass basic test', () => { + expect(1 + 1).toBe(2); + }); +}); + +describe('Environment', () => { + it('should have NODE_ENV set', () => { + expect(process.env.NODE_ENV).toBeDefined(); + }); +}); diff --git a/apps/server/src/config/env.ts b/apps/server/src/config/env.ts new file mode 100644 index 0000000..79ee50c --- /dev/null +++ b/apps/server/src/config/env.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import 'dotenv/config'; + +const envSchema = z.object({ + PORT: z.string().default('4000').transform(Number), + HOST: z.string().default('0.0.0.0'), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + DATABASE_URL: z.string().url(), + JWT_SECRET: z.string().min(32), + GOOGLE_CLIENT_ID: z.string(), + GOOGLE_CLIENT_SECRET: z.string(), + GOOGLE_CALLBACK_URL: z.string().url(), + FRONTEND_URL: z.string().url(), +}); + +const parsed = envSchema.safeParse(process.env); + +if (!parsed.success) { + console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors); + process.exit(1); +} + +export const env = parsed.data; +export type Env = z.infer; diff --git a/apps/server/src/config/index.ts b/apps/server/src/config/index.ts new file mode 100644 index 0000000..7915515 --- /dev/null +++ b/apps/server/src/config/index.ts @@ -0,0 +1 @@ +export * from './env.js'; diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..389c690 --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,84 @@ +import Fastify, { FastifyInstance } from 'fastify'; +import cors from '@fastify/cors'; +import jwt from '@fastify/jwt'; +import cookie from '@fastify/cookie'; +import { env } from './config/env.js'; +import { prisma } from './lib/prisma.js'; +import { authRoutes } from './routes/auth.js'; +import { userRoutes } from './routes/users.js'; +import { conversationRoutes } from './routes/conversations.js'; +import { setupSocketIO } from './socket/index.js'; + +export async function buildApp(): Promise { + const app = Fastify({ + logger: { + level: env.NODE_ENV === 'development' ? 'info' : 'warn', + transport: env.NODE_ENV === 'development' ? { + target: 'pino-pretty', + options: { colorize: true }, + } : undefined, + }, + }); + + // Register plugins + await app.register(cors, { + origin: env.FRONTEND_URL, + credentials: true, + }); + + await app.register(cookie, { + secret: env.JWT_SECRET, + parseOptions: {}, + }); + + await app.register(jwt, { + secret: env.JWT_SECRET, + cookie: { + cookieName: 'token', + signed: false, + }, + }); + + // Decorate request with user + app.decorateRequest('user', null); + + // Health check + app.get('/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() })); + + // Register routes + await app.register(authRoutes, { prefix: '/auth' }); + await app.register(userRoutes); + await app.register(conversationRoutes, { prefix: '/conversations' }); + + return app; +} + +async function main() { + const app = await buildApp(); + + // Setup Socket.IO + const io = setupSocketIO(app.server); + + // Graceful shutdown + const shutdown = async () => { + console.log('Shutting down...'); + await app.close(); + await prisma.$disconnect(); + io.close(); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + try { + await app.listen({ port: env.PORT, host: env.HOST }); + console.log(`🚀 Server running at http://${env.HOST}:${env.PORT}`); + console.log(`📡 WebSocket server ready`); + } catch (err) { + app.log.error(err); + process.exit(1); + } +} + +main(); diff --git a/apps/server/src/lib/index.ts b/apps/server/src/lib/index.ts new file mode 100644 index 0000000..f8003b1 --- /dev/null +++ b/apps/server/src/lib/index.ts @@ -0,0 +1 @@ +export * from './prisma.js'; diff --git a/apps/server/src/lib/prisma.ts b/apps/server/src/lib/prisma.ts new file mode 100644 index 0000000..084d9b8 --- /dev/null +++ b/apps/server/src/lib/prisma.ts @@ -0,0 +1,17 @@ +import { PrismaClient } from '@prisma/client'; +import { env } from '../config/env.js'; + +declare global { + // eslint-disable-next-line no-var + var prisma: PrismaClient | undefined; +} + +export const prisma = global.prisma || new PrismaClient({ + log: env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], +}); + +if (env.NODE_ENV !== 'production') { + global.prisma = prisma; +} + +export type { PrismaClient } from '@prisma/client'; diff --git a/apps/server/src/middleware/auth.ts b/apps/server/src/middleware/auth.ts new file mode 100644 index 0000000..d0cb8a5 --- /dev/null +++ b/apps/server/src/middleware/auth.ts @@ -0,0 +1,18 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; + +export async function authenticate(request: FastifyRequest, reply: FastifyReply) { + try { + await request.jwtVerify(); + } catch (err) { + reply.status(401).send({ error: 'Unauthorized', message: 'Invalid or missing token' }); + } +} + +export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply) { + try { + await request.jwtVerify(); + } catch { + // Token is optional, so we don't throw an error + request.user = null; + } +} diff --git a/apps/server/src/middleware/index.ts b/apps/server/src/middleware/index.ts new file mode 100644 index 0000000..0cc7659 --- /dev/null +++ b/apps/server/src/middleware/index.ts @@ -0,0 +1 @@ +export * from './auth.js'; diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts new file mode 100644 index 0000000..dc7bc84 --- /dev/null +++ b/apps/server/src/routes/auth.ts @@ -0,0 +1,136 @@ +import { FastifyInstance } from 'fastify'; +import { env } from '../config/env.js'; +import { prisma } from '../lib/prisma.js'; + +interface GoogleTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + id_token: string; +} + +interface GoogleUserInfo { + sub: string; + email: string; + name: string; + picture?: string; + email_verified: boolean; +} + +export async function authRoutes(app: FastifyInstance) { + // Initiate Google OAuth + app.get('/google', async (request, reply) => { + const redirectUri = env.GOOGLE_CALLBACK_URL; + const scope = encodeURIComponent('openid email profile'); + const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?` + + `client_id=${env.GOOGLE_CLIENT_ID}` + + `&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&response_type=code` + + `&scope=${scope}` + + `&access_type=offline` + + `&prompt=consent`; + + return reply.redirect(googleAuthUrl); + }); + + // Google OAuth callback + app.get('/google/callback', async (request, reply) => { + const { code } = request.query as { code?: string }; + + if (!code) { + return reply.status(400).send({ error: 'Missing authorization code' }); + } + + try { + // Exchange code for tokens + const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET, + redirect_uri: env.GOOGLE_CALLBACK_URL, + grant_type: 'authorization_code', + }), + }); + + if (!tokenResponse.ok) { + const error = await tokenResponse.text(); + app.log.error('Token exchange failed:', error); + return reply.status(400).send({ error: 'Failed to exchange code for tokens' }); + } + + const tokens = await tokenResponse.json() as GoogleTokenResponse; + + // Get user info + const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + + if (!userInfoResponse.ok) { + return reply.status(400).send({ error: 'Failed to get user info' }); + } + + const googleUser = await userInfoResponse.json() as GoogleUserInfo; + + // Upsert user in database + const user = await prisma.user.upsert({ + where: { googleSub: googleUser.sub }, + update: { + email: googleUser.email, + name: googleUser.name, + avatarUrl: googleUser.picture || null, + }, + create: { + googleSub: googleUser.sub, + email: googleUser.email, + name: googleUser.name, + avatarUrl: googleUser.picture || null, + }, + }); + + // Generate JWT + const token = app.jwt.sign({ + id: user.id, + email: user.email, + name: user.name, + avatarUrl: user.avatarUrl, + }, { expiresIn: '7d' }); + + // Set cookie and redirect to frontend + reply.setCookie('token', token, { + httpOnly: true, + secure: env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 7 * 24 * 60 * 60, // 7 days + }); + + // Redirect to frontend with token in URL for client-side storage + return reply.redirect(`${env.FRONTEND_URL}/auth/callback?token=${token}`); + } catch (error) { + app.log.error('OAuth callback error:', error); + return reply.status(500).send({ error: 'Authentication failed' }); + } + }); + + // Logout + app.post('/logout', async (_request, reply) => { + reply.clearCookie('token', { path: '/' }); + return { success: true }; + }); + + // Verify token endpoint (for Socket.IO auth) + app.get('/verify', { + preHandler: async (request, reply) => { + try { + await request.jwtVerify(); + } catch { + return reply.status(401).send({ error: 'Unauthorized' }); + } + }, + }, async (request) => { + return { valid: true, user: request.user }; + }); +} diff --git a/apps/server/src/routes/conversations.ts b/apps/server/src/routes/conversations.ts new file mode 100644 index 0000000..abb41be --- /dev/null +++ b/apps/server/src/routes/conversations.ts @@ -0,0 +1,444 @@ +import { FastifyInstance } from 'fastify'; +import { authenticate } from '../middleware/auth.js'; +import { prisma } from '../lib/prisma.js'; +import { + createDmRequestSchema, + createGroupRequestSchema, + addMemberRequestSchema, + messagesQuerySchema, +} from '../schemas/index.js'; + +export async function conversationRoutes(app: FastifyInstance) { + // List user's conversations + app.get('/', { + preHandler: authenticate, + }, async (request, reply) => { + if (!request.user) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + + const conversations = await prisma.conversation.findMany({ + where: { + members: { + some: { userId: request.user.id }, + }, + }, + include: { + members: { + include: { + user: { + select: { id: true, email: true, name: true, avatarUrl: true }, + }, + }, + }, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { + id: true, + body: true, + createdAt: true, + sender: { select: { id: true, name: true } }, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + }); + + // Transform response for DMs to show other user's name + const transformed = conversations.map((conv) => { + if (conv.type === 'dm') { + const otherMember = conv.members.find((m) => m.userId !== request.user!.id); + return { + ...conv, + displayName: otherMember?.user.name || 'Unknown', + displayAvatar: otherMember?.user.avatarUrl, + }; + } + return { + ...conv, + displayName: conv.title || 'Group', + displayAvatar: null, + }; + }); + + return transformed; + }); + + // Create DM conversation + app.post('/dm', { + preHandler: authenticate, + }, async (request, reply) => { + if (!request.user) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + + const parseResult = createDmRequestSchema.safeParse(request.body); + if (!parseResult.success) { + return reply.status(400).send({ error: 'Invalid request', details: parseResult.error.errors }); + } + + const { email } = parseResult.data; + + // Find target user + const targetUser = await prisma.user.findUnique({ + where: { email }, + select: { id: true, email: true, name: true, avatarUrl: true }, + }); + + if (!targetUser) { + return reply.status(404).send({ error: 'User not found' }); + } + + if (targetUser.id === request.user.id) { + return reply.status(400).send({ error: 'Cannot create DM with yourself' }); + } + + // Check if DM already exists between these users + const existingDm = await prisma.conversation.findFirst({ + where: { + type: 'dm', + AND: [ + { members: { some: { userId: request.user.id } } }, + { members: { some: { userId: targetUser.id } } }, + ], + }, + include: { + members: { + include: { + user: { select: { id: true, email: true, name: true, avatarUrl: true } }, + }, + }, + }, + }); + + if (existingDm) { + return existingDm; + } + + // Create new DM conversation + const conversation = await prisma.conversation.create({ + data: { + type: 'dm', + members: { + create: [ + { userId: request.user.id, role: 'member' }, + { userId: targetUser.id, role: 'member' }, + ], + }, + }, + include: { + members: { + include: { + user: { select: { id: true, email: true, name: true, avatarUrl: true } }, + }, + }, + }, + }); + + return conversation; + }); + + // Create group conversation + app.post('/group', { + preHandler: authenticate, + }, async (request, reply) => { + if (!request.user) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + + const parseResult = createGroupRequestSchema.safeParse(request.body); + if (!parseResult.success) { + return reply.status(400).send({ error: 'Invalid request', details: parseResult.error.errors }); + } + + const { title, memberEmails } = parseResult.data; + + // Find all target users + const targetUsers = await prisma.user.findMany({ + where: { email: { in: memberEmails } }, + select: { id: true, email: true, name: true, avatarUrl: true }, + }); + + if (targetUsers.length !== memberEmails.length) { + const foundEmails = targetUsers.map((u) => u.email); + const notFound = memberEmails.filter((e) => !foundEmails.includes(e)); + return reply.status(404).send({ error: 'Some users not found', notFound }); + } + + // Create group conversation with owner and members + const conversation = await prisma.conversation.create({ + data: { + type: 'group', + title, + members: { + create: [ + { userId: request.user.id, role: 'owner' }, + ...targetUsers.map((u) => ({ userId: u.id, role: 'member' as const })), + ], + }, + }, + include: { + members: { + include: { + user: { select: { id: true, email: true, name: true, avatarUrl: true } }, + }, + }, + }, + }); + + return conversation; + }); + + // Get single conversation + app.get('/:id', { + preHandler: authenticate, + }, async (request, reply) => { + if (!request.user) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + + const { id } = request.params as { id: string }; + + const conversation = await prisma.conversation.findFirst({ + where: { + id, + members: { some: { userId: request.user.id } }, + }, + include: { + members: { + include: { + user: { select: { id: true, email: true, name: true, avatarUrl: true } }, + }, + }, + }, + }); + + if (!conversation) { + return reply.status(404).send({ error: 'Conversation not found' }); + } + + return conversation; + }); + + // Add member to group + app.post('/:id/members', { + preHandler: authenticate, + }, async (request, reply) => { + if (!request.user) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + + const { id } = request.params as { id: string }; + const parseResult = addMemberRequestSchema.safeParse(request.body); + + if (!parseResult.success) { + return reply.status(400).send({ error: 'Invalid request', details: parseResult.error.errors }); + } + + const { email } = parseResult.data; + + // Check user is owner/admin of the group + const membership = await prisma.conversationMember.findFirst({ + where: { + conversationId: id, + userId: request.user.id, + role: { in: ['owner', 'admin'] }, + }, + include: { conversation: true }, + }); + + if (!membership) { + return reply.status(403).send({ error: 'Not authorized to add members' }); + } + + if (membership.conversation.type !== 'group') { + return reply.status(400).send({ error: 'Cannot add members to DM' }); + } + + // Find target user + const targetUser = await prisma.user.findUnique({ + where: { email }, + select: { id: true }, + }); + + if (!targetUser) { + return reply.status(404).send({ error: 'User not found' }); + } + + // Check if already a member + const existingMember = await prisma.conversationMember.findUnique({ + where: { + conversationId_userId: { + conversationId: id, + userId: targetUser.id, + }, + }, + }); + + if (existingMember) { + return reply.status(400).send({ error: 'User is already a member' }); + } + + // Add member + await prisma.conversationMember.create({ + data: { + conversationId: id, + userId: targetUser.id, + role: 'member', + }, + }); + + // Return updated conversation + const conversation = await prisma.conversation.findUnique({ + where: { id }, + include: { + members: { + include: { + user: { select: { id: true, email: true, name: true, avatarUrl: true } }, + }, + }, + }, + }); + + return conversation; + }); + + // Remove member from group + app.delete('/:id/members/:userId', { + preHandler: authenticate, + }, async (request, reply) => { + if (!request.user) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + + const { id, userId } = request.params as { id: string; userId: string }; + + // Check user is owner/admin or removing themselves + const membership = await prisma.conversationMember.findFirst({ + where: { + conversationId: id, + userId: request.user.id, + }, + include: { conversation: true }, + }); + + if (!membership) { + return reply.status(403).send({ error: 'Not a member of this conversation' }); + } + + if (membership.conversation.type !== 'group') { + return reply.status(400).send({ error: 'Cannot remove members from DM' }); + } + + const isAdmin = membership.role === 'owner' || membership.role === 'admin'; + const isSelf = userId === request.user.id; + + if (!isAdmin && !isSelf) { + return reply.status(403).send({ error: 'Not authorized to remove members' }); + } + + // Don't allow owner to be removed + const targetMembership = await prisma.conversationMember.findUnique({ + where: { + conversationId_userId: { + conversationId: id, + userId, + }, + }, + }); + + if (targetMembership?.role === 'owner') { + return reply.status(400).send({ error: 'Cannot remove the owner' }); + } + + await prisma.conversationMember.delete({ + where: { + conversationId_userId: { + conversationId: id, + userId, + }, + }, + }); + + return { success: true }; + }); + + // Get messages for conversation + app.get('/:id/messages', { + preHandler: authenticate, + }, async (request, reply) => { + if (!request.user) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + + const { id } = request.params as { id: string }; + const parseResult = messagesQuerySchema.safeParse(request.query); + + if (!parseResult.success) { + return reply.status(400).send({ error: 'Invalid query', details: parseResult.error.errors }); + } + + const { after, limit } = parseResult.data; + + // Check user is a member + const membership = await prisma.conversationMember.findUnique({ + where: { + conversationId_userId: { + conversationId: id, + userId: request.user.id, + }, + }, + }); + + if (!membership) { + return reply.status(403).send({ error: 'Not a member of this conversation' }); + } + + // Build query + const whereClause: { conversationId: string; createdAt?: { gt: Date } } = { + conversationId: id, + }; + + if (after) { + const afterMessage = await prisma.message.findUnique({ + where: { id: after }, + select: { createdAt: true }, + }); + + if (afterMessage) { + whereClause.createdAt = { gt: afterMessage.createdAt }; + } + } + + const messages = await prisma.message.findMany({ + where: whereClause, + include: { + sender: { + select: { id: true, name: true, avatarUrl: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + take: limit, + }); + + // Update last seen + if (messages.length > 0) { + const lastMessage = messages[messages.length - 1]; + await prisma.conversationMember.update({ + where: { + conversationId_userId: { + conversationId: id, + userId: request.user.id, + }, + }, + data: { + lastSeenMsgId: lastMessage.id, + lastSeenAt: new Date(), + }, + }); + } + + return messages; + }); +} diff --git a/apps/server/src/routes/index.ts b/apps/server/src/routes/index.ts new file mode 100644 index 0000000..2b607d8 --- /dev/null +++ b/apps/server/src/routes/index.ts @@ -0,0 +1,3 @@ +export * from './auth.js'; +export * from './users.js'; +export * from './conversations.js'; diff --git a/apps/server/src/routes/users.ts b/apps/server/src/routes/users.ts new file mode 100644 index 0000000..9721b35 --- /dev/null +++ b/apps/server/src/routes/users.ts @@ -0,0 +1,58 @@ +import { FastifyInstance } from 'fastify'; +import { authenticate } from '../middleware/auth.js'; +import { prisma } from '../lib/prisma.js'; + +export async function userRoutes(app: FastifyInstance) { + // Get current user + app.get('/me', { + preHandler: authenticate, + }, async (request, reply) => { + if (!request.user) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + + const user = await prisma.user.findUnique({ + where: { id: request.user.id }, + select: { + id: true, + email: true, + name: true, + avatarUrl: true, + createdAt: true, + }, + }); + + if (!user) { + return reply.status(404).send({ error: 'User not found' }); + } + + return user; + }); + + // Search users by email (for adding to conversations) + app.get('/users/search', { + preHandler: authenticate, + }, async (request, reply) => { + const { email } = request.query as { email?: string }; + + if (!email || email.length < 3) { + return reply.status(400).send({ error: 'Email query must be at least 3 characters' }); + } + + const users = await prisma.user.findMany({ + where: { + email: { contains: email, mode: 'insensitive' }, + NOT: { id: request.user!.id }, + }, + select: { + id: true, + email: true, + name: true, + avatarUrl: true, + }, + take: 10, + }); + + return users; + }); +} diff --git a/apps/server/src/schemas/index.ts b/apps/server/src/schemas/index.ts new file mode 100644 index 0000000..246275e --- /dev/null +++ b/apps/server/src/schemas/index.ts @@ -0,0 +1,91 @@ +import { z } from 'zod'; + +// User schemas +export const userSchema = z.object({ + id: z.string().uuid(), + googleSub: z.string(), + email: z.string().email(), + name: z.string(), + avatarUrl: z.string().url().nullable(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type User = z.infer; + +// Conversation schemas +export const conversationTypeSchema = z.enum(['dm', 'group']); +export type ConversationType = z.infer; + +export const conversationSchema = z.object({ + id: z.string().uuid(), + type: conversationTypeSchema, + title: z.string().nullable(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type Conversation = z.infer; + +// Member schemas +export const memberRoleSchema = z.enum(['owner', 'admin', 'member']); +export type MemberRole = z.infer; + +export const conversationMemberSchema = z.object({ + id: z.string().uuid(), + conversationId: z.string().uuid(), + userId: z.string().uuid(), + role: memberRoleSchema, + joinedAt: z.date(), + lastSeenAt: z.date().nullable(), + lastSeenMsgId: z.string().uuid().nullable(), +}); + +export type ConversationMember = z.infer; + +// Message schemas +export const messageSchema = z.object({ + id: z.string().uuid(), + conversationId: z.string().uuid(), + senderId: z.string().uuid(), + clientMsgId: z.string().uuid(), + body: z.string(), + createdAt: z.date(), +}); + +export type Message = z.infer; + +// API Request/Response schemas +export const createDmRequestSchema = z.object({ + email: z.string().email(), +}); + +export const createGroupRequestSchema = z.object({ + title: z.string().min(1).max(100), + memberEmails: z.array(z.string().email()).min(1), +}); + +export const addMemberRequestSchema = z.object({ + email: z.string().email(), +}); + +export const messagesQuerySchema = z.object({ + after: z.string().uuid().optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), +}); + +// WebSocket event schemas +export const wsMessageSendSchema = z.object({ + conversationId: z.string().uuid(), + clientMsgId: z.string().uuid(), + body: z.string().min(1).max(10000), +}); + +export const wsSyncRequestSchema = z.object({ + conversationId: z.string().uuid(), + afterMessageId: z.string().uuid().optional(), +}); + +export const wsAuthSchema = z.object({ + token: z.string(), +}); diff --git a/apps/server/src/socket/index.ts b/apps/server/src/socket/index.ts new file mode 100644 index 0000000..a96aab4 --- /dev/null +++ b/apps/server/src/socket/index.ts @@ -0,0 +1,313 @@ +import { Server as HttpServer } from 'http'; +import { Server, Socket } from 'socket.io'; +import jwt from 'jsonwebtoken'; +import { env } from '../config/env.js'; +import { prisma } from '../lib/prisma.js'; +import { wsMessageSendSchema, wsSyncRequestSchema } from '../schemas/index.js'; + +interface AuthenticatedSocket extends Socket { + userId: string; + userEmail: string; + userName: string; +} + +interface JWTPayload { + id: string; + email: string; + name: string; + avatarUrl: string | null; +} + +// Store online users: userId -> Set of socket IDs +const onlineUsers = new Map>(); + +export function setupSocketIO(httpServer: HttpServer): Server { + const io = new Server(httpServer, { + cors: { + origin: env.FRONTEND_URL, + credentials: true, + }, + pingTimeout: 60000, + pingInterval: 25000, + }); + + // Authentication middleware + io.use((socket, next) => { + const token = socket.handshake.auth.token || socket.handshake.headers.authorization?.split(' ')[1]; + + if (!token) { + return next(new Error('Authentication required')); + } + + try { + const payload = jwt.verify(token, env.JWT_SECRET) as JWTPayload; + (socket as AuthenticatedSocket).userId = payload.id; + (socket as AuthenticatedSocket).userEmail = payload.email; + (socket as AuthenticatedSocket).userName = payload.name; + next(); + } catch { + next(new Error('Invalid token')); + } + }); + + io.on('connection', async (socket: Socket) => { + const authSocket = socket as AuthenticatedSocket; + const userId = authSocket.userId; + + console.log(`User connected: ${authSocket.userName} (${userId})`); + + // Track online status + if (!onlineUsers.has(userId)) { + onlineUsers.set(userId, new Set()); + } + onlineUsers.get(userId)!.add(socket.id); + + // Join user to their conversation rooms + await joinUserConversations(authSocket); + + // Handle message send + socket.on('message:send', async (data, ack) => { + try { + const parseResult = wsMessageSendSchema.safeParse(data); + if (!parseResult.success) { + socket.emit('error', { message: 'Invalid message format', details: parseResult.error.errors }); + return; + } + + const { conversationId, clientMsgId, body } = parseResult.data; + + // Verify user is member of conversation + const membership = await prisma.conversationMember.findUnique({ + where: { + conversationId_userId: { + conversationId, + userId, + }, + }, + }); + + if (!membership) { + socket.emit('error', { message: 'Not a member of this conversation' }); + return; + } + + // Check for duplicate (idempotency) + const existingMessage = await prisma.message.findUnique({ + where: { + senderId_clientMsgId: { + senderId: userId, + clientMsgId, + }, + }, + }); + + if (existingMessage) { + // Return existing message (idempotent response) + const ackPayload = { + clientMsgId, + messageId: existingMessage.id, + createdAt: existingMessage.createdAt.toISOString(), + }; + if (typeof ack === 'function') { + ack(ackPayload); + } else { + socket.emit('message:ack', ackPayload); + } + return; + } + + // Create message + const message = await prisma.message.create({ + data: { + conversationId, + senderId: userId, + clientMsgId, + body, + }, + include: { + sender: { + select: { id: true, name: true, avatarUrl: true }, + }, + }, + }); + + // Update conversation timestamp + await prisma.conversation.update({ + where: { id: conversationId }, + data: { updatedAt: new Date() }, + }); + + // Acknowledge to sender + const ackPayload = { + clientMsgId, + messageId: message.id, + createdAt: message.createdAt.toISOString(), + }; + + if (typeof ack === 'function') { + ack(ackPayload); + } else { + socket.emit('message:ack', ackPayload); + } + + // Broadcast to all members in the conversation room (including sender for multi-device) + io.to(`conversation:${conversationId}`).emit('message:new', { + messageId: message.id, + conversationId, + senderId: message.senderId, + senderName: message.sender.name, + senderAvatar: message.sender.avatarUrl, + clientMsgId, + body: message.body, + createdAt: message.createdAt.toISOString(), + }); + + } catch (error) { + console.error('Error handling message:send:', error); + socket.emit('error', { message: 'Failed to send message' }); + } + }); + + // Handle sync request (offline catch-up) + socket.on('sync:request', async (data) => { + try { + const parseResult = wsSyncRequestSchema.safeParse(data); + if (!parseResult.success) { + socket.emit('error', { message: 'Invalid sync request', details: parseResult.error.errors }); + return; + } + + const { conversationId, afterMessageId } = parseResult.data; + + // Verify user is member of conversation + const membership = await prisma.conversationMember.findUnique({ + where: { + conversationId_userId: { + conversationId, + userId, + }, + }, + }); + + if (!membership) { + socket.emit('error', { message: 'Not a member of this conversation' }); + return; + } + + // Build query + const whereClause: { conversationId: string; createdAt?: { gt: Date } } = { + conversationId, + }; + + if (afterMessageId) { + const afterMessage = await prisma.message.findUnique({ + where: { id: afterMessageId }, + select: { createdAt: true }, + }); + + if (afterMessage) { + whereClause.createdAt = { gt: afterMessage.createdAt }; + } + } + + const messages = await prisma.message.findMany({ + where: whereClause, + include: { + sender: { + select: { id: true, name: true, avatarUrl: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + take: 100, // Limit sync batch + }); + + socket.emit('sync:batch', { + conversationId, + messages: messages.map((m) => ({ + messageId: m.id, + conversationId: m.conversationId, + senderId: m.senderId, + senderName: m.sender.name, + senderAvatar: m.sender.avatarUrl, + clientMsgId: m.clientMsgId, + body: m.body, + createdAt: m.createdAt.toISOString(), + })), + }); + + // Update last seen + if (messages.length > 0) { + const lastMessage = messages[messages.length - 1]; + await prisma.conversationMember.update({ + where: { + conversationId_userId: { + conversationId, + userId, + }, + }, + data: { + lastSeenMsgId: lastMessage.id, + lastSeenAt: new Date(), + }, + }); + } + + } catch (error) { + console.error('Error handling sync:request:', error); + socket.emit('error', { message: 'Failed to sync messages' }); + } + }); + + // Handle typing indicator + socket.on('typing:start', async (data: { conversationId: string }) => { + socket.to(`conversation:${data.conversationId}`).emit('typing:start', { + conversationId: data.conversationId, + userId, + userName: authSocket.userName, + }); + }); + + socket.on('typing:stop', async (data: { conversationId: string }) => { + socket.to(`conversation:${data.conversationId}`).emit('typing:stop', { + conversationId: data.conversationId, + userId, + }); + }); + + // Handle disconnect + socket.on('disconnect', () => { + console.log(`User disconnected: ${authSocket.userName} (${userId})`); + + const userSockets = onlineUsers.get(userId); + if (userSockets) { + userSockets.delete(socket.id); + if (userSockets.size === 0) { + onlineUsers.delete(userId); + } + } + }); + }); + + return io; +} + +async function joinUserConversations(socket: AuthenticatedSocket) { + const memberships = await prisma.conversationMember.findMany({ + where: { userId: socket.userId }, + select: { conversationId: true }, + }); + + for (const membership of memberships) { + socket.join(`conversation:${membership.conversationId}`); + } + + console.log(`User ${socket.userName} joined ${memberships.length} conversation rooms`); +} + +export function isUserOnline(userId: string): boolean { + return onlineUsers.has(userId) && onlineUsers.get(userId)!.size > 0; +} + +export function getOnlineUsers(): string[] { + return Array.from(onlineUsers.keys()); +} diff --git a/apps/server/src/types/fastify.ts b/apps/server/src/types/fastify.ts new file mode 100644 index 0000000..64c59d9 --- /dev/null +++ b/apps/server/src/types/fastify.ts @@ -0,0 +1,31 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +declare module 'fastify' { + interface FastifyRequest { + user: { + id: string; + email: string; + name: string; + avatarUrl: string | null; + } | null; + } +} + +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: { + id: string; + email: string; + name: string; + avatarUrl: string | null; + }; + user: { + id: string; + email: string; + name: string; + avatarUrl: string | null; + }; + } +} + +export type { FastifyInstance, FastifyRequest, FastifyReply }; diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..71c9632 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts new file mode 100644 index 0000000..40c8129 --- /dev/null +++ b/apps/server/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + coverage: { + reporter: ['text', 'json', 'html'], + exclude: ['node_modules/', 'dist/'], + }, + }, +}); diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 0000000..ba2800b --- /dev/null +++ b/apps/web/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.next +.git +*.log diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..e7c7363 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,7 @@ +# Next.js Configuration +NEXT_PUBLIC_API_URL=http://localhost:4000 +NEXT_PUBLIC_WS_URL=http://localhost:4000 + +# NextAuth.js +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET=your-nextauth-secret-change-this-in-production diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/apps/web/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..2a7ad13 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,29 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build outputs +.next/ +out/ +build/ + +# Environment files +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Logs +logs/ +*.log +npm-debug.log* + +# Misc +*.tgz +.cache/ diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..da8ffc8 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,51 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build arguments for environment variables +ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_WS_URL + +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy built files +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +# Set ownership +RUN chown -R nextjs:nodejs /app + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 0000000..4fb095b --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,20 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'lh3.googleusercontent.com', + pathname: '/**', + }, + ], + }, + experimental: { + serverActions: { + bodySizeLimit: '2mb', + }, + }, +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..862dc6b --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,35 @@ +{ + "name": "@oschat/web", + "version": "1.0.0", + "description": "OSChat Frontend - Next.js", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "next": "14.2.5", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "socket.io-client": "^4.7.5", + "zod": "^3.23.8", + "zustand": "^4.5.4", + "lucide-react": "^0.411.0", + "clsx": "^2.1.1", + "tailwind-merge": "^2.4.0" + }, + "devDependencies": { + "@types/node": "^20.14.10", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-config-next": "14.2.5", + "postcss": "^8.4.39", + "tailwindcss": "^3.4.6", + "typescript": "^5.5.3" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/src/app/auth/callback/page.tsx b/apps/web/src/app/auth/callback/page.tsx new file mode 100644 index 0000000..9c0e122 --- /dev/null +++ b/apps/web/src/app/auth/callback/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useEffect, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useAuthStore } from '@/stores/auth'; +import { Loader2 } from 'lucide-react'; + +function CallbackContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { setToken, fetchUser } = useAuthStore(); + + useEffect(() => { + const token = searchParams.get('token'); + + if (token) { + setToken(token); + fetchUser().then(() => { + router.push('/chat'); + }).catch(() => { + router.push('/?error=auth_failed'); + }); + } else { + router.push('/?error=no_token'); + } + }, [searchParams, setToken, fetchUser, router]); + + return ( +
+
+ +

Signing you in...

+
+
+ ); +} + +export default function AuthCallback() { + return ( + +
+ +

Loading...

+
+ + } + > + +
+ ); +} diff --git a/apps/web/src/app/chat/page.tsx b/apps/web/src/app/chat/page.tsx new file mode 100644 index 0000000..49893b8 --- /dev/null +++ b/apps/web/src/app/chat/page.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Menu, X } from 'lucide-react'; +import { useAuthStore } from '@/stores/auth'; +import { useConversationStore } from '@/stores/conversations'; +import { ConversationList, MessageArea, SocketProvider } from '@/components/chat'; +import { Spinner } from '@/components/ui'; +import { cn } from '@/lib/utils'; + +export default function ChatPage() { + const router = useRouter(); + const { isAuthenticated, isLoading, token, fetchUser } = useAuthStore(); + const { fetchConversations } = useConversationStore(); + const [sidebarOpen, setSidebarOpen] = useState(false); + + // Check auth on mount + useEffect(() => { + if (token && !isAuthenticated) { + fetchUser(); + } + }, [token, isAuthenticated, fetchUser]); + + // Redirect if not authenticated + useEffect(() => { + if (!isLoading && !isAuthenticated && !token) { + router.push('/'); + } + }, [isLoading, isAuthenticated, token, router]); + + // Fetch conversations when authenticated + useEffect(() => { + if (isAuthenticated) { + fetchConversations(); + } + }, [isAuthenticated, fetchConversations]); + + if (isLoading || (!isAuthenticated && token)) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return ( + +
+ {/* Mobile sidebar backdrop */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Sidebar */} + + + {/* Main content */} +
+ {/* Mobile header */} +
+ + OSChat +
+ + +
+
+ + ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 0000000..9c0d1b5 --- /dev/null +++ b/apps/web/src/app/globals.css @@ -0,0 +1,49 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + @apply h-full; + } + body { + @apply h-full bg-gray-50 text-gray-900 antialiased; + } +} + +@layer components { + .btn { + @apply inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed; + } + .btn-primary { + @apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500; + } + .btn-secondary { + @apply btn bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-primary-500; + } + .btn-ghost { + @apply btn text-gray-600 hover:bg-gray-100 focus:ring-primary-500; + } + .input { + @apply w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent; + } +} + +/* Custom scrollbar */ +@layer utilities { + .scrollbar-thin { + scrollbar-width: thin; + } + .scrollbar-thin::-webkit-scrollbar { + width: 6px; + } + .scrollbar-thin::-webkit-scrollbar-track { + @apply bg-gray-100; + } + .scrollbar-thin::-webkit-scrollbar-thumb { + @apply bg-gray-300 rounded-full; + } + .scrollbar-thin::-webkit-scrollbar-thumb:hover { + @apply bg-gray-400; + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..937a502 --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; +import './globals.css'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata: Metadata = { + title: 'OSChat - Real-time Chat', + description: 'A modern real-time chat application', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx new file mode 100644 index 0000000..c889609 --- /dev/null +++ b/apps/web/src/app/page.tsx @@ -0,0 +1,128 @@ +import Link from 'next/link'; +import { MessageSquare, Shield, Zap, Users } from 'lucide-react'; + +export default function Home() { + return ( +
+ {/* Header */} +
+ +
+ + {/* Hero */} +
+
+
+
+

+ Real-time chat for everyone +

+

+ Connect with friends and colleagues instantly. Create group conversations, + send direct messages, and stay in sync across all your devices. +

+
+ + Get started + + + Learn more + +
+
+
+
+ + {/* Features */} +
+
+
+

+ Features +

+

+ Everything you need to stay connected +

+
+
+
+
+
+
+ +
+ Real-time messaging +
+
+ Messages are delivered instantly using WebSocket technology. + No refresh needed. +
+
+
+
+
+ +
+ Group conversations +
+
+ Create groups with multiple members. Add or remove members as needed. +
+
+
+
+
+ +
+ Secure authentication +
+
+ Sign in securely with your Google account. Your data is protected. +
+
+
+
+
+ +
+ Offline sync +
+
+ Never miss a message. Catch up on everything when you reconnect. +
+
+
+
+
+
+
+ + {/* Footer */} +
+
+

+ © {new Date().getFullYear()} OSChat. All rights reserved. +

+
+
+
+ ); +} diff --git a/apps/web/src/components/chat/ConversationList.tsx b/apps/web/src/components/chat/ConversationList.tsx new file mode 100644 index 0000000..bf8e3e4 --- /dev/null +++ b/apps/web/src/components/chat/ConversationList.tsx @@ -0,0 +1,327 @@ +'use client'; + +import { useState } from 'react'; +import { Plus, MessageSquare, Users, Search, LogOut, X } from 'lucide-react'; +import { useAuthStore } from '@/stores/auth'; +import { useConversationStore, Conversation } from '@/stores/conversations'; +import { Avatar, Spinner } from '@/components/ui'; +import { cn, formatDate } from '@/lib/utils'; + +interface ConversationListProps { + onClose?: () => void; +} + +export function ConversationList({ onClose }: ConversationListProps) { + const { user, logout } = useAuthStore(); + const { + conversations, + currentConversationId, + isLoading, + setCurrentConversation, + } = useConversationStore(); + + const [showNewConversation, setShowNewConversation] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + const filteredConversations = conversations.filter((conv) => + conv.displayName.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const handleSelectConversation = (id: string) => { + setCurrentConversation(id); + onClose?.(); + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+

{user?.name}

+

{user?.email}

+
+
+ +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm bg-gray-100 border-0 rounded-lg focus:ring-2 focus:ring-primary-500" + /> +
+
+ + {/* New Conversation Button */} +
+ +
+ + {/* Conversation List */} +
+ {isLoading ? ( +
+ +
+ ) : filteredConversations.length === 0 ? ( +
+ +

+ {searchQuery ? 'No conversations found' : 'No conversations yet'} +

+ {!searchQuery && ( + + )} +
+ ) : ( +
    + {filteredConversations.map((conv) => ( + handleSelectConversation(conv.id)} + /> + ))} +
+ )} +
+ + {/* New Conversation Modal */} + {showNewConversation && ( + setShowNewConversation(false)} /> + )} +
+ ); +} + +interface ConversationItemProps { + conversation: Conversation; + isActive: boolean; + onClick: () => void; +} + +function ConversationItem({ conversation, isActive, onClick }: ConversationItemProps) { + const lastMessage = conversation.messages?.[0]; + + return ( +
  • + +
  • + ); +} + +interface NewConversationModalProps { + onClose: () => void; +} + +function NewConversationModal({ onClose }: NewConversationModalProps) { + const [mode, setMode] = useState<'dm' | 'group'>('dm'); + const [email, setEmail] = useState(''); + const [groupTitle, setGroupTitle] = useState(''); + const [memberEmails, setMemberEmails] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const { createDm, createGroup } = useConversationStore(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + try { + if (mode === 'dm') { + await createDm(email.trim()); + } else { + const emails = memberEmails.split(',').map((e) => e.trim()).filter(Boolean); + if (emails.length === 0) { + setError('Please enter at least one member email'); + setIsLoading(false); + return; + } + await createGroup(groupTitle.trim(), emails); + } + onClose(); + } catch (err) { + setError((err as Error).message); + } finally { + setIsLoading(false); + } + }; + + return ( +
    +
    +
    +

    New Conversation

    + +
    + +
    + {/* Mode Toggle */} +
    + + +
    + +
    + {mode === 'dm' ? ( +
    + + setEmail(e.target.value)} + placeholder="user@example.com" + className="input" + required + /> +
    + ) : ( + <> +
    + + setGroupTitle(e.target.value)} + placeholder="My Group" + className="input" + required + /> +
    +
    + + -
    - - - - - - \ No newline at end of file diff --git a/public/style.css b/public/style.css deleted file mode 100644 index 6ce8fbf..0000000 --- a/public/style.css +++ /dev/null @@ -1,74 +0,0 @@ -@import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); -* { - padding: 0; - margin: 0; - box-sizing: border-box; -} - -body { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - background: #F8F8F8; - font-family: 'Roboto', sans-serif; -} -section.chat__section { - width: 800px; - max-width: 90%; - background: #fff; - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); -} -.brand { - padding: 20px; - background: #f1f1f1; - display: flex; - align-items: center; -} -.brand h1 { - text-transform: uppercase; - font-size: 20px; - color: #444; - margin-left: 10px; -} -.message__area{ - height: 500px; - padding: 16px; - display: flex; - flex-direction: column; - overflow-y: auto; - padding-top: 40px; -} -textarea { - width: 100%; - border: none; - padding: 20px; - font-size: 16px; - outline: none; - background: #FBFBFB; - resize: none; -} - -.message { - padding: 20px; - border-radius: 4px; - margin-bottom: 40px; - max-width: 300px; - position: relative; -} -.incoming { - background: #8F8BE8; - color: #fff; -} -.outgoing { - background: #e9eafd; - color: #787986; - margin-left: auto; -} -.message h4 { - position: absolute; - top: -20px; - left: 0; - color: #333; - font-size: 14px; -} \ No newline at end of file diff --git a/public/tchat.png b/public/tchat.png deleted file mode 100644 index 253b21d4bc9130620100ffba2570b2dd5d592b18..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38520 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 ZfhrE~dM!m?OgaEOH?N!NS6#gy@h^{__b>ne From 763360996f41cbf520b07532e8513b20fc28fb97 Mon Sep 17 00:00:00 2001 From: demonkiller Date: Tue, 13 Jan 2026 14:27:17 +0000 Subject: [PATCH 07/20] new packages --- manifest/deployment.yml | 22 ---------------------- manifest/service.yml | 12 ------------ package.json | 38 ++++++++++++++++++++++++++------------ 3 files changed, 26 insertions(+), 46 deletions(-) delete mode 100644 manifest/deployment.yml delete mode 100644 manifest/service.yml diff --git a/manifest/deployment.yml b/manifest/deployment.yml deleted file mode 100644 index 991d003..0000000 --- a/manifest/deployment.yml +++ /dev/null @@ -1,22 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: oschat - labels: - app: oschat -spec: - replicas: 2 - selector: - matchLabels: - app: oschat - template: - metadata: - labels: - app: oschat - spec: - containers: - - name: oschat - image: harshau04/oschat:latest - ports: - - containerPort: 3000 - diff --git a/manifest/service.yml b/manifest/service.yml deleted file mode 100644 index 7b658b1..0000000 --- a/manifest/service.yml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: oschat -spec: - type: NodePort - selector: - app: oschat - ports: - - port: 3000 - targetPort: 3000 - nodePort: 30007 diff --git a/package.json b/package.json index 81a77fc..e6b9102 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,40 @@ { "name": "oschat", "version": "2.0.0", - "description": "Real-time chat application", - "main": "server.js", - "type": "commonjs", + "description": "Real-time chat application with TypeScript, Next.js, and Fastify", + "private": true, + "workspaces": [ + "apps/*" + ], "scripts": { - "start": "node server.js", - "dev": "nodemon server.js", - "serve": "node server.js" + "dev": "concurrently \"npm run dev:server\" \"npm run dev:web\"", + "dev:server": "npm run dev -w @oschat/server", + "dev:web": "npm run dev -w @oschat/web", + "build": "npm run build -w @oschat/server && npm run build -w @oschat/web", + "build:server": "npm run build -w @oschat/server", + "build:web": "npm run build -w @oschat/web", + "start:server": "npm run start -w @oschat/server", + "start:web": "npm run start -w @oschat/web", + "lint": "npm run lint -w @oschat/server && npm run lint -w @oschat/web", + "typecheck": "npm run typecheck -w @oschat/server && npm run typecheck -w @oschat/web", + "test": "npm run test -w @oschat/server", + "db:generate": "npm run db:generate -w @oschat/server", + "db:migrate": "npm run db:migrate -w @oschat/server", + "db:push": "npm run db:push -w @oschat/server", + "db:studio": "npm run db:studio -w @oschat/server", + "docker:up": "docker compose up -d", + "docker:down": "docker compose down", + "docker:dev": "docker compose -f docker-compose.dev.yml up -d" }, - "keywords": ["chat", "socket.io", "real-time"], + "keywords": ["chat", "socket.io", "real-time", "nextjs", "fastify", "typescript"], "author": "demonkiller", "license": "MIT", - "dependencies": { - "express": "^4.21.2", - "socket.io": "^4.8.1" - }, "devDependencies": { - "nodemon": "^3.1.9" + "concurrently": "^8.2.2" }, "engines": { "node": ">=20.0.0", "npm": ">=9.0.0" } } + From d85cbb366f61bb9fd62decf519f1afebc4280734 Mon Sep 17 00:00:00 2001 From: demonkiller Date: Mon, 19 Jan 2026 16:29:57 +0000 Subject: [PATCH 08/20] revamp --- apps/api/package.json | 32 ++ apps/api/prisma/schema.prisma | 76 +++ apps/api/src/index.ts | 87 ++++ apps/api/src/middleware/auth.ts | 7 + apps/api/src/routes/auth.ts | 104 ++++ apps/api/src/routes/conversations.ts | 266 +++++++++++ apps/api/src/routes/messages.ts | 90 ++++ apps/api/src/socket.ts | 181 +++++++ apps/api/src/types/fastify.d.ts | 9 + apps/{server => api}/tsconfig.json | 44 +- apps/server/.dockerignore | 7 - apps/server/.env.example | 19 - apps/server/.eslintrc.cjs | 22 - apps/server/.gitignore | 33 -- apps/server/Dockerfile | 45 -- apps/server/package.json | 42 -- .../20260112000000_init/migration.sql | 87 ---- apps/server/prisma/schema.prisma | 90 ---- apps/server/src/__tests__/basic.test.ts | 13 - apps/server/src/config/env.ts | 24 - apps/server/src/config/index.ts | 1 - apps/server/src/index.ts | 84 ---- apps/server/src/lib/index.ts | 1 - apps/server/src/lib/prisma.ts | 17 - apps/server/src/middleware/auth.ts | 18 - apps/server/src/middleware/index.ts | 1 - apps/server/src/routes/auth.ts | 136 ------ apps/server/src/routes/conversations.ts | 444 ------------------ apps/server/src/routes/index.ts | 3 - apps/server/src/routes/users.ts | 58 --- apps/server/src/schemas/index.ts | 91 ---- apps/server/src/socket/index.ts | 313 ------------ apps/server/src/types/fastify.ts | 31 -- apps/server/vitest.config.ts | 13 - apps/web/.dockerignore | 4 - apps/web/.env.example | 7 - apps/web/.eslintrc.json | 3 - apps/web/.gitignore | 29 -- apps/web/Dockerfile | 51 -- apps/web/next-env.d.ts | 10 +- apps/web/next.config.mjs | 29 +- apps/web/package.json | 76 +-- apps/web/postcss.config.js | 6 - apps/web/postcss.config.mjs | 9 + apps/web/src/app/auth/callback/page.tsx | 78 +-- apps/web/src/app/chat/page.tsx | 133 ++---- apps/web/src/app/globals.css | 110 +++-- apps/web/src/app/layout.tsx | 44 +- apps/web/src/app/login/page.tsx | 50 ++ apps/web/src/app/page.tsx | 152 +----- apps/web/src/components/chat/ChatLayout.tsx | 137 ++++++ apps/web/src/components/chat/ChatWindow.tsx | 193 ++++++++ .../src/components/chat/ConversationItem.tsx | 81 ++++ .../src/components/chat/ConversationList.tsx | 364 ++------------ .../components/chat/GroupCreationPanel.tsx | 387 +++++++++++++++ apps/web/src/components/chat/MessageArea.tsx | 212 --------- .../web/src/components/chat/MessageBubble.tsx | 48 ++ .../src/components/chat/SocketProvider.tsx | 69 --- apps/web/src/components/chat/UserSearch.tsx | 119 +++++ apps/web/src/components/chat/index.ts | 3 - apps/web/src/components/ui/Avatar.tsx | 46 -- apps/web/src/components/ui/Spinner.tsx | 23 - apps/web/src/components/ui/index.ts | 2 - apps/web/src/lib/api.ts | 120 +++-- apps/web/src/lib/index.ts | 3 - apps/web/src/lib/socket.ts | 176 ------- apps/web/src/lib/socket.tsx | 92 ++++ apps/web/src/lib/store.ts | 129 +++++ apps/web/src/lib/utils.ts | 55 +-- apps/web/src/stores/auth.ts | 85 ---- apps/web/src/stores/conversations.ts | 133 ------ apps/web/src/stores/index.ts | 3 - apps/web/src/stores/messages.ts | 273 ----------- apps/web/src/types/index.ts | 72 --- apps/web/tailwind.config.js | 28 -- apps/web/tailwind.config.ts | 56 +++ apps/web/tsconfig.json | 53 ++- assets/tchat-ss.png | Bin 54163 -> 0 bytes assets/tchat-ss2.png | Bin 43845 -> 0 bytes 79 files changed, 2520 insertions(+), 3722 deletions(-) create mode 100755 apps/api/package.json create mode 100755 apps/api/prisma/schema.prisma create mode 100755 apps/api/src/index.ts create mode 100755 apps/api/src/middleware/auth.ts create mode 100755 apps/api/src/routes/auth.ts create mode 100755 apps/api/src/routes/conversations.ts create mode 100755 apps/api/src/routes/messages.ts create mode 100755 apps/api/src/socket.ts create mode 100755 apps/api/src/types/fastify.d.ts rename apps/{server => api}/tsconfig.json (64%) mode change 100644 => 100755 delete mode 100644 apps/server/.dockerignore delete mode 100644 apps/server/.env.example delete mode 100644 apps/server/.eslintrc.cjs delete mode 100644 apps/server/.gitignore delete mode 100644 apps/server/Dockerfile delete mode 100644 apps/server/package.json delete mode 100644 apps/server/prisma/migrations/20260112000000_init/migration.sql delete mode 100644 apps/server/prisma/schema.prisma delete mode 100644 apps/server/src/__tests__/basic.test.ts delete mode 100644 apps/server/src/config/env.ts delete mode 100644 apps/server/src/config/index.ts delete mode 100644 apps/server/src/index.ts delete mode 100644 apps/server/src/lib/index.ts delete mode 100644 apps/server/src/lib/prisma.ts delete mode 100644 apps/server/src/middleware/auth.ts delete mode 100644 apps/server/src/middleware/index.ts delete mode 100644 apps/server/src/routes/auth.ts delete mode 100644 apps/server/src/routes/conversations.ts delete mode 100644 apps/server/src/routes/index.ts delete mode 100644 apps/server/src/routes/users.ts delete mode 100644 apps/server/src/schemas/index.ts delete mode 100644 apps/server/src/socket/index.ts delete mode 100644 apps/server/src/types/fastify.ts delete mode 100644 apps/server/vitest.config.ts delete mode 100644 apps/web/.dockerignore delete mode 100644 apps/web/.env.example delete mode 100644 apps/web/.eslintrc.json delete mode 100644 apps/web/.gitignore delete mode 100644 apps/web/Dockerfile mode change 100644 => 100755 apps/web/next-env.d.ts mode change 100644 => 100755 apps/web/next.config.mjs mode change 100644 => 100755 apps/web/package.json delete mode 100644 apps/web/postcss.config.js create mode 100755 apps/web/postcss.config.mjs mode change 100644 => 100755 apps/web/src/app/auth/callback/page.tsx mode change 100644 => 100755 apps/web/src/app/chat/page.tsx mode change 100644 => 100755 apps/web/src/app/globals.css mode change 100644 => 100755 apps/web/src/app/layout.tsx create mode 100755 apps/web/src/app/login/page.tsx mode change 100644 => 100755 apps/web/src/app/page.tsx create mode 100755 apps/web/src/components/chat/ChatLayout.tsx create mode 100755 apps/web/src/components/chat/ChatWindow.tsx create mode 100755 apps/web/src/components/chat/ConversationItem.tsx mode change 100644 => 100755 apps/web/src/components/chat/ConversationList.tsx create mode 100755 apps/web/src/components/chat/GroupCreationPanel.tsx delete mode 100644 apps/web/src/components/chat/MessageArea.tsx create mode 100755 apps/web/src/components/chat/MessageBubble.tsx delete mode 100644 apps/web/src/components/chat/SocketProvider.tsx create mode 100755 apps/web/src/components/chat/UserSearch.tsx delete mode 100644 apps/web/src/components/chat/index.ts delete mode 100644 apps/web/src/components/ui/Avatar.tsx delete mode 100644 apps/web/src/components/ui/Spinner.tsx delete mode 100644 apps/web/src/components/ui/index.ts mode change 100644 => 100755 apps/web/src/lib/api.ts delete mode 100644 apps/web/src/lib/index.ts delete mode 100644 apps/web/src/lib/socket.ts create mode 100755 apps/web/src/lib/socket.tsx create mode 100755 apps/web/src/lib/store.ts mode change 100644 => 100755 apps/web/src/lib/utils.ts delete mode 100644 apps/web/src/stores/auth.ts delete mode 100644 apps/web/src/stores/conversations.ts delete mode 100644 apps/web/src/stores/index.ts delete mode 100644 apps/web/src/stores/messages.ts delete mode 100644 apps/web/src/types/index.ts delete mode 100644 apps/web/tailwind.config.js create mode 100755 apps/web/tailwind.config.ts mode change 100644 => 100755 apps/web/tsconfig.json delete mode 100644 assets/tchat-ss.png delete mode 100644 assets/tchat-ss2.png diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100755 index 0000000..fe5746e --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,32 @@ +{ + "name": "api", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:studio": "prisma studio" + }, + "dependencies": { + "fastify": "^5.2.0", + "@fastify/cors": "^10.0.1", + "@fastify/cookie": "^10.0.1", + "@fastify/jwt": "^9.0.1", + "socket.io": "^4.8.1", + "@prisma/client": "^6.2.1", + "zod": "^3.24.1", + "axios": "^1.7.9", + "bcrypt": "^5.1.1", + "dotenv": "^16.4.7" + }, + "devDependencies": { + "@types/node": "^22", + "@types/bcrypt": "^5.0.2", + "prisma": "^6.2.1", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100755 index 0000000..a398394 --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,76 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(uuid()) + email String @unique + name String + avatar String? + googleId String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + sentMessages Message[] @relation("SentMessages") + conversationMembers ConversationMember[] + readReceipts ReadReceipt[] +} + +model Conversation { + id String @id @default(uuid()) + type String // "direct" or "group" + name String? // Only for group chats + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + members ConversationMember[] + messages Message[] +} + +model ConversationMember { + id String @id @default(uuid()) + userId String + conversationId String + joinedAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + + @@unique([userId, conversationId]) + @@index([userId]) + @@index([conversationId]) +} + +model Message { + id String @id @default(uuid()) + content String + senderId String + conversationId String + createdAt DateTime @default(now()) + + sender User @relation("SentMessages", fields: [senderId], references: [id], onDelete: Cascade) + conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + readReceipts ReadReceipt[] + + @@index([conversationId]) + @@index([senderId]) +} + +model ReadReceipt { + id String @id @default(uuid()) + messageId String + userId String + readAt DateTime @default(now()) + + message Message @relation(fields: [messageId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([messageId, userId]) + @@index([messageId]) + @@index([userId]) +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100755 index 0000000..9983ef1 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,87 @@ +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import cookie from "@fastify/cookie"; +import jwt from "@fastify/jwt"; +import { Server } from "socket.io"; +import dotenv from "dotenv"; +import { PrismaClient } from "@prisma/client"; +import authRoutes from "./routes/auth"; +import conversationRoutes from "./routes/conversations"; +import messageRoutes from "./routes/messages"; +import { setupSocketIO } from "./socket"; + +dotenv.config(); + +const prisma = new PrismaClient(); +const fastify = Fastify({ logger: true }); + +const PORT = process.env.PORT || 3001; +const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; + +async function start() { + try { + // Register plugins + await fastify.register(cors, { + origin: FRONTEND_URL, + credentials: true, + }); + + await fastify.register(cookie); + + await fastify.register(jwt, { + secret: process.env.JWT_SECRET || "super-secret-key", + cookie: { + cookieName: "token", + signed: false, + }, + }); + + // Decorate fastify with prisma + fastify.decorate("prisma", prisma); + + // Decorate fastify with authenticate method + fastify.decorate("authenticate", async function (request: any, reply: any) { + try { + await request.jwtVerify(); + } catch (err) { + reply.code(401).send({ error: "Unauthorized" }); + } + }); + + // Register routes + await fastify.register(authRoutes, { prefix: "/auth" }); + await fastify.register(conversationRoutes, { prefix: "/conversations" }); + await fastify.register(messageRoutes, { prefix: "/messages" }); + + // Health check + fastify.get("/health", async () => { + return { status: "ok" }; + }); + + // Start server + await fastify.listen({ port: PORT as number, host: "0.0.0.0" }); + + // Setup Socket.IO + const io = new Server(fastify.server, { + cors: { + origin: FRONTEND_URL, + credentials: true, + }, + }); + + setupSocketIO(io, prisma); + + console.log(`Server running on http://localhost:${PORT}`); + } catch (err) { + fastify.log.error(err); + process.exit(1); + } +} + +// Graceful shutdown +process.on("SIGINT", async () => { + await prisma.$disconnect(); + process.exit(0); +}); + +start(); diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts new file mode 100755 index 0000000..5893429 --- /dev/null +++ b/apps/api/src/middleware/auth.ts @@ -0,0 +1,7 @@ +import { FastifyRequest, FastifyReply } from "fastify"; + +declare module "fastify" { + interface FastifyInstance { + authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; + } +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100755 index 0000000..a112c7d --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,104 @@ +import { FastifyPluginAsync } from "fastify"; +import axios from "axios"; + +const authRoutes: FastifyPluginAsync = async (fastify) => { + // Google OAuth login URL + fastify.get("/google", async (request, reply) => { + const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${process.env.GOOGLE_CLIENT_ID}&redirect_uri=${process.env.GOOGLE_CALLBACK_URL}&response_type=code&scope=profile email`; + + reply.redirect(googleAuthUrl); + }); + + // Google OAuth callback + fastify.get("/google/callback", async (request, reply) => { + const { code } = request.query as { code: string }; + + if (!code) { + return reply.code(400).send({ error: "No code provided" }); + } + + try { + // Exchange code for tokens + const tokenResponse = await axios.post("https://oauth2.googleapis.com/token", { + code, + client_id: process.env.GOOGLE_CLIENT_ID, + client_secret: process.env.GOOGLE_CLIENT_SECRET, + redirect_uri: process.env.GOOGLE_CALLBACK_URL, + grant_type: "authorization_code", + }); + + const { access_token } = tokenResponse.data; + + // Get user info + const userInfoResponse = await axios.get("https://www.googleapis.com/oauth2/v2/userinfo", { + headers: { Authorization: `Bearer ${access_token}` }, + }); + + const { id, email, name, picture } = userInfoResponse.data; + + // Create or update user + let user = await fastify.prisma.user.findUnique({ + where: { googleId: id }, + }); + + if (!user) { + user = await fastify.prisma.user.create({ + data: { + googleId: id, + email, + name, + avatar: picture, + }, + }); + } else { + user = await fastify.prisma.user.update({ + where: { id: user.id }, + data: { + name, + avatar: picture, + }, + }); + } + + // Generate JWT + const token = fastify.jwt.sign({ userId: user.id }); + + // Redirect to frontend with token + reply.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${token}`); + } catch (error) { + fastify.log.error(error); + reply.code(500).send({ error: "Authentication failed" }); + } + }); + + // Get current user + fastify.get("/me", { + onRequest: [fastify.authenticate], + }, async (request, reply) => { + const decoded = await request.jwtVerify() as { userId: string }; + + const user = await fastify.prisma.user.findUnique({ + where: { id: decoded.userId }, + select: { + id: true, + email: true, + name: true, + avatar: true, + createdAt: true, + }, + }); + + if (!user) { + return reply.code(404).send({ error: "User not found" }); + } + + reply.send(user); + }); + + // Logout + fastify.post("/logout", async (request, reply) => { + reply.send({ message: "Logged out successfully" }); + }); +}; + +export default authRoutes; diff --git a/apps/api/src/routes/conversations.ts b/apps/api/src/routes/conversations.ts new file mode 100755 index 0000000..e1e31fe --- /dev/null +++ b/apps/api/src/routes/conversations.ts @@ -0,0 +1,266 @@ +import { FastifyPluginAsync } from "fastify"; + +const conversationRoutes: FastifyPluginAsync = async (fastify) => { + // Search users (for creating conversations) - MUST BE BEFORE /:id route + fastify.get("/users/search", { + onRequest: [fastify.authenticate], + }, async (request, reply) => { + const decoded = await request.jwtVerify() as { userId: string }; + const { q } = request.query as { q?: string }; + + const users = await fastify.prisma.user.findMany({ + where: { + AND: [ + { id: { not: decoded.userId } }, + q ? { + OR: [ + { name: { contains: q } }, + { email: { contains: q } }, + ], + } : {}, + ], + }, + select: { + id: true, + name: true, + avatar: true, + email: true, + }, + take: 20, + }); + + reply.send(users); + }); + + // Get all conversations for current user + fastify.get("/", { + onRequest: [fastify.authenticate], + }, async (request, reply) => { + const decoded = await request.jwtVerify() as { userId: string }; + + const conversations = await fastify.prisma.conversation.findMany({ + where: { + members: { + some: { + userId: decoded.userId, + }, + }, + }, + include: { + members: { + include: { + user: { + select: { + id: true, + name: true, + avatar: true, + email: true, + }, + }, + }, + }, + messages: { + orderBy: { + createdAt: "desc", + }, + take: 1, + include: { + sender: { + select: { + id: true, + name: true, + avatar: true, + }, + }, + }, + }, + }, + orderBy: { + updatedAt: "desc", + }, + }); + + reply.send(conversations); + }); + + // Create a new conversation (1:1 or group) + fastify.post("/", { + onRequest: [fastify.authenticate], + }, async (request, reply) => { + const decoded = await request.jwtVerify() as { userId: string }; + const { type, memberIds, name } = request.body as { + type: "direct" | "group"; + memberIds: string[]; + name?: string; + }; + + // Validate + if (type === "direct" && memberIds.length !== 1) { + return reply.code(400).send({ error: "Direct conversations must have exactly 1 other member" }); + } + + if (type === "group" && memberIds.length < 2) { + return reply.code(400).send({ error: "Group conversations must have at least 2 other members" }); + } + + // Check if direct conversation already exists + if (type === "direct") { + const existingConversation = await fastify.prisma.conversation.findFirst({ + where: { + type: "direct", + members: { + every: { + userId: { + in: [decoded.userId, memberIds[0]], + }, + }, + }, + }, + include: { + members: { + include: { + user: { + select: { + id: true, + name: true, + avatar: true, + email: true, + }, + }, + }, + }, + }, + }); + + if (existingConversation && existingConversation.members.length === 2) { + return reply.send(existingConversation); + } + } + + // Create conversation + const allMemberIds = [decoded.userId, ...memberIds]; + const conversation = await fastify.prisma.conversation.create({ + data: { + type, + name: type === "group" ? name : null, + members: { + create: allMemberIds.map((id) => ({ + userId: id, + })), + }, + }, + include: { + members: { + include: { + user: { + select: { + id: true, + name: true, + avatar: true, + email: true, + }, + }, + }, + }, + }, + }); + + reply.send(conversation); + }); + + // Get conversation by ID + fastify.get("/:id", { + onRequest: [fastify.authenticate], + }, async (request, reply) => { + const decoded = await request.jwtVerify() as { userId: string }; + const { id } = request.params as { id: string }; + + const conversation = await fastify.prisma.conversation.findFirst({ + where: { + id, + members: { + some: { + userId: decoded.userId, + }, + }, + }, + include: { + members: { + include: { + user: { + select: { + id: true, + name: true, + avatar: true, + email: true, + }, + }, + }, + }, + }, + }); + + if (!conversation) { + return reply.code(404).send({ error: "Conversation not found" }); + } + + reply.send(conversation); + }); + + // Add members to group conversation + fastify.post("/:id/members", { + onRequest: [fastify.authenticate], + }, async (request, reply) => { + const decoded = await request.jwtVerify() as { userId: string }; + const { id } = request.params as { id: string }; + const { memberIds } = request.body as { memberIds: string[] }; + + // Check if conversation exists and user is a member + const conversation = await fastify.prisma.conversation.findFirst({ + where: { + id, + type: "group", + members: { + some: { + userId: decoded.userId, + }, + }, + }, + }); + + if (!conversation) { + return reply.code(404).send({ error: "Group conversation not found" }); + } + + // Add members + await fastify.prisma.conversationMember.createMany({ + data: memberIds.map((userId) => ({ + conversationId: id, + userId, + })), + skipDuplicates: true, + }); + + const updatedConversation = await fastify.prisma.conversation.findUnique({ + where: { id }, + include: { + members: { + include: { + user: { + select: { + id: true, + name: true, + avatar: true, + email: true, + }, + }, + }, + }, + }, + }); + + reply.send(updatedConversation); + }); +}; + +export default conversationRoutes; diff --git a/apps/api/src/routes/messages.ts b/apps/api/src/routes/messages.ts new file mode 100755 index 0000000..88d856d --- /dev/null +++ b/apps/api/src/routes/messages.ts @@ -0,0 +1,90 @@ +import { FastifyPluginAsync } from "fastify"; + +const messageRoutes: FastifyPluginAsync = async (fastify) => { + // Get messages for a conversation + fastify.get("/:conversationId", { + onRequest: [fastify.authenticate], + }, async (request, reply) => { + const decoded = await request.jwtVerify() as { userId: string }; + const { conversationId } = request.params as { conversationId: string }; + const { limit = "50", before } = request.query as { limit?: string; before?: string }; + + // Check if user is member of conversation + const membership = await fastify.prisma.conversationMember.findFirst({ + where: { + conversationId, + userId: decoded.userId, + }, + }); + + if (!membership) { + return reply.code(403).send({ error: "Not a member of this conversation" }); + } + + const messages = await fastify.prisma.message.findMany({ + where: { + conversationId, + ...(before ? { createdAt: { lt: new Date(before) } } : {}), + }, + include: { + sender: { + select: { + id: true, + name: true, + avatar: true, + }, + }, + readReceipts: { + include: { + user: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + take: parseInt(limit), + }); + + reply.send(messages.reverse()); + }); + + // Mark messages as read + fastify.post("/:conversationId/read", { + onRequest: [fastify.authenticate], + }, async (request, reply) => { + const decoded = await request.jwtVerify() as { userId: string }; + const { conversationId } = request.params as { conversationId: string }; + const { messageIds } = request.body as { messageIds: string[] }; + + // Check if user is member of conversation + const membership = await fastify.prisma.conversationMember.findFirst({ + where: { + conversationId, + userId: decoded.userId, + }, + }); + + if (!membership) { + return reply.code(403).send({ error: "Not a member of this conversation" }); + } + + // Create read receipts + await fastify.prisma.readReceipt.createMany({ + data: messageIds.map((messageId) => ({ + messageId, + userId: decoded.userId, + })), + skipDuplicates: true, + }); + + reply.send({ success: true }); + }); +}; + +export default messageRoutes; diff --git a/apps/api/src/socket.ts b/apps/api/src/socket.ts new file mode 100755 index 0000000..5dc302b --- /dev/null +++ b/apps/api/src/socket.ts @@ -0,0 +1,181 @@ +import { Server, Socket } from "socket.io"; +import { PrismaClient } from "@prisma/client"; + +interface AuthenticatedSocket extends Socket { + userId?: string; +} + +interface TypingData { + conversationId: string; + userName: string; +} + +interface SendMessageData { + conversationId: string; + content: string; +} + +export function setupSocketIO(io: Server, prisma: PrismaClient) { + // Middleware to authenticate socket connections + io.use(async (socket: AuthenticatedSocket, next) => { + const token = socket.handshake.auth.token; + + if (!token) { + return next(new Error("Authentication error")); + } + + try { + // In production, verify JWT token here + // For now, we'll extract userId from token (simplified) + const payload = JSON.parse( + Buffer.from(token.split(".")[1], "base64").toString() + ); + socket.userId = payload.userId; + next(); + } catch (error) { + next(new Error("Authentication error")); + } + }); + + io.on("connection", async (socket: AuthenticatedSocket) => { + console.log(`User connected: ${socket.userId}`); + + if (!socket.userId) { + socket.disconnect(); + return; + } + + // Join user's personal room (for direct notifications) + socket.join(`user:${socket.userId}`); + + // Get user's conversations and join their rooms + const conversations = await prisma.conversation.findMany({ + where: { + members: { + some: { + userId: socket.userId, + }, + }, + }, + select: { + id: true, + }, + }); + + conversations.forEach((conv) => { + socket.join(`conversation:${conv.id}`); + }); + + // Emit online status + socket.broadcast.emit("user:online", { userId: socket.userId }); + + // Join conversation room + socket.on("join:conversation", (conversationId: string) => { + socket.join(`conversation:${conversationId}`); + console.log(`User ${socket.userId} joined conversation ${conversationId}`); + }); + + // Leave conversation room + socket.on("leave:conversation", (conversationId: string) => { + socket.leave(`conversation:${conversationId}`); + console.log(`User ${socket.userId} left conversation ${conversationId}`); + }); + + // Send message + socket.on("message:send", async (data: SendMessageData) => { + try { + const { conversationId, content } = data; + + // Verify user is member of conversation + const membership = await prisma.conversationMember.findFirst({ + where: { + conversationId, + userId: socket.userId, + }, + }); + + if (!membership) { + socket.emit("error", { message: "Not a member of this conversation" }); + return; + } + + // Create message + const message = await prisma.message.create({ + data: { + content, + senderId: socket.userId!, + conversationId, + }, + include: { + sender: { + select: { + id: true, + name: true, + avatar: true, + }, + }, + readReceipts: true, + }, + }); + + // Update conversation timestamp + await prisma.conversation.update({ + where: { id: conversationId }, + data: { updatedAt: new Date() }, + }); + + // Emit to all users in conversation + io.to(`conversation:${conversationId}`).emit("message:new", message); + } catch (error) { + console.error("Error sending message:", error); + socket.emit("error", { message: "Failed to send message" }); + } + }); + + // Typing indicator + socket.on("typing:start", async (data: TypingData) => { + const { conversationId, userName } = data; + socket.to(`conversation:${conversationId}`).emit("typing:start", { + conversationId, + userId: socket.userId, + userName, + }); + }); + + socket.on("typing:stop", (conversationId: string) => { + socket.to(`conversation:${conversationId}`).emit("typing:stop", { + conversationId, + userId: socket.userId, + }); + }); + + // Message read + socket.on("message:read", async (data: { messageId: string; conversationId: string }) => { + try { + const { messageId, conversationId } = data; + + // Create read receipt + await prisma.readReceipt.create({ + data: { + messageId, + userId: socket.userId!, + }, + }); + + // Emit to conversation + io.to(`conversation:${conversationId}`).emit("message:read", { + messageId, + userId: socket.userId, + }); + } catch (error) { + // Ignore duplicate read receipts + } + }); + + // Disconnect + socket.on("disconnect", () => { + console.log(`User disconnected: ${socket.userId}`); + socket.broadcast.emit("user:offline", { userId: socket.userId }); + }); + }); +} diff --git a/apps/api/src/types/fastify.d.ts b/apps/api/src/types/fastify.d.ts new file mode 100755 index 0000000..0237206 --- /dev/null +++ b/apps/api/src/types/fastify.d.ts @@ -0,0 +1,9 @@ +import { PrismaClient } from "@prisma/client"; +import { FastifyRequest, FastifyReply } from "fastify"; + +declare module "fastify" { + interface FastifyInstance { + prisma: PrismaClient; + authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; + } +} diff --git a/apps/server/tsconfig.json b/apps/api/tsconfig.json old mode 100644 new mode 100755 similarity index 64% rename from apps/server/tsconfig.json rename to apps/api/tsconfig.json index 71c9632..0ccd0cd --- a/apps/server/tsconfig.json +++ b/apps/api/tsconfig.json @@ -1,24 +1,20 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022"], - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/server/.dockerignore b/apps/server/.dockerignore deleted file mode 100644 index 828c06d..0000000 --- a/apps/server/.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -dist -.env -.env.local -coverage -.next -*.log diff --git a/apps/server/.env.example b/apps/server/.env.example deleted file mode 100644 index eb9802a..0000000 --- a/apps/server/.env.example +++ /dev/null @@ -1,19 +0,0 @@ -# Server Configuration -PORT=4000 -HOST=0.0.0.0 -NODE_ENV=development - -# Database (PostgreSQL) -DATABASE_URL=postgresql://oschat:oschat@localhost:5432/oschat?schema=public - -# JWT Secret (Change in production!) -JWT_SECRET=your-super-secret-jwt-key-change-this-in-production - -# Google OAuth -GOOGLE_CLIENT_ID=your-google-client-id -GOOGLE_CLIENT_SECRET=your-google-client-secret -GOOGLE_CALLBACK_URL=http://localhost:4000/auth/google/callback - -# Frontend URL (for CORS and redirects) -FRONTEND_URL=http://localhost:3000 -GOOGLE_CALLBACK_URL=http://localhost:4000/api/auth/google/callback diff --git a/apps/server/.eslintrc.cjs b/apps/server/.eslintrc.cjs deleted file mode 100644 index cf6090f..0000000 --- a/apps/server/.eslintrc.cjs +++ /dev/null @@ -1,22 +0,0 @@ -module.exports = { - root: true, - env: { - node: true, - es2022: true, - }, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - ], - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - }, - plugins: ['@typescript-eslint'], - rules: { - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/no-explicit-any': 'warn', - }, -}; diff --git a/apps/server/.gitignore b/apps/server/.gitignore deleted file mode 100644 index 623a265..0000000 --- a/apps/server/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# Dependencies -node_modules/ -.pnp -.pnp.js - -# Build outputs -dist/ -.next/ -out/ -build/ - -# Environment files -.env -.env.local -.env.*.local - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# Logs -logs/ -*.log -npm-debug.log* - -# Testing -coverage/ - -# Misc -*.tgz -.cache/ diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile deleted file mode 100644 index d4a9301..0000000 --- a/apps/server/Dockerfile +++ /dev/null @@ -1,45 +0,0 @@ -# Build stage -FROM node:20-alpine AS builder - -WORKDIR /app - -# Copy package files -COPY package.json ./ - -# Install dependencies -RUN npm install - -# Copy source code -COPY . . - -# Generate Prisma client -RUN npx prisma generate - -# Build the application -RUN npm run build - -# Production stage -FROM node:20-alpine AS runner - -WORKDIR /app - -ENV NODE_ENV=production - -# Create non-root user -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 fastify - -# Copy built files -COPY --from=builder /app/dist ./dist -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/package.json ./package.json -COPY --from=builder /app/prisma ./prisma - -# Set ownership -RUN chown -R fastify:nodejs /app - -USER fastify - -EXPOSE 4000 - -CMD ["npm", "start"] diff --git a/apps/server/package.json b/apps/server/package.json deleted file mode 100644 index e1f2946..0000000 --- a/apps/server/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@oschat/server", - "version": "1.0.0", - "description": "OSChat Backend - Fastify + Socket.IO", - "main": "dist/index.js", - "type": "module", - "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsc", - "start": "node dist/index.js", - "lint": "eslint src --ext .ts", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest", - "db:generate": "prisma generate", - "db:migrate": "prisma migrate dev", - "db:push": "prisma db push", - "db:studio": "prisma studio" - }, - "dependencies": { - "@fastify/cookie": "^9.3.1", - "@fastify/cors": "^9.0.1", - "@fastify/jwt": "^8.0.1", - "@fastify/oauth2": "^7.8.1", - "@fastify/websocket": "^10.0.1", - "@prisma/client": "^5.19.0", - "dotenv": "^16.4.5", - "fastify": "^4.28.1", - "socket.io": "^4.7.5", - "zod": "^3.23.8" - }, - "devDependencies": { - "@types/node": "^20.14.10", - "eslint": "^8.57.0", - "@typescript-eslint/eslint-plugin": "^7.16.0", - "@typescript-eslint/parser": "^7.16.0", - "prisma": "^5.19.0", - "tsx": "^4.16.2", - "typescript": "^5.5.3", - "vitest": "^2.0.3" - } -} diff --git a/apps/server/prisma/migrations/20260112000000_init/migration.sql b/apps/server/prisma/migrations/20260112000000_init/migration.sql deleted file mode 100644 index 71d1fd6..0000000 --- a/apps/server/prisma/migrations/20260112000000_init/migration.sql +++ /dev/null @@ -1,87 +0,0 @@ --- CreateEnum -CREATE TYPE "ConversationType" AS ENUM ('dm', 'group'); - --- CreateEnum -CREATE TYPE "MemberRole" AS ENUM ('owner', 'admin', 'member'); - --- CreateTable -CREATE TABLE "users" ( - "id" TEXT NOT NULL, - "google_sub" TEXT NOT NULL, - "email" TEXT NOT NULL, - "name" TEXT NOT NULL, - "avatar_url" TEXT, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "users_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "conversations" ( - "id" TEXT NOT NULL, - "type" "ConversationType" NOT NULL, - "title" TEXT, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "conversations_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "conversation_members" ( - "id" TEXT NOT NULL, - "conversation_id" TEXT NOT NULL, - "user_id" TEXT NOT NULL, - "role" "MemberRole" NOT NULL DEFAULT 'member', - "joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "last_seen_at" TIMESTAMP(3), - "last_seen_msg_id" TEXT, - - CONSTRAINT "conversation_members_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "messages" ( - "id" TEXT NOT NULL, - "conversation_id" TEXT NOT NULL, - "sender_id" TEXT NOT NULL, - "client_msg_id" TEXT NOT NULL, - "body" TEXT NOT NULL, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "messages_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "users_google_sub_key" ON "users"("google_sub"); - --- CreateIndex -CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); - --- CreateIndex -CREATE INDEX "conversation_members_user_id_idx" ON "conversation_members"("user_id"); - --- CreateIndex -CREATE UNIQUE INDEX "conversation_members_conversation_id_user_id_key" ON "conversation_members"("conversation_id", "user_id"); - --- CreateIndex -CREATE INDEX "messages_conversation_id_created_at_idx" ON "messages"("conversation_id", "created_at"); - --- CreateIndex -CREATE UNIQUE INDEX "messages_sender_id_client_msg_id_key" ON "messages"("sender_id", "client_msg_id"); - --- AddForeignKey -ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_last_seen_msg_id_fkey" FOREIGN KEY ("last_seen_msg_id") REFERENCES "messages"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma deleted file mode 100644 index 2decab4..0000000 --- a/apps/server/prisma/schema.prisma +++ /dev/null @@ -1,90 +0,0 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -generator client { - provider = "prisma-client-js" -} - -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id String @id @default(uuid()) - googleSub String @unique @map("google_sub") - email String @unique - name String - avatarUrl String? @map("avatar_url") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - // Relations - memberships ConversationMember[] - messages Message[] - - @@map("users") -} - -enum ConversationType { - dm - group -} - -model Conversation { - id String @id @default(uuid()) - type ConversationType - title String? - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - // Relations - members ConversationMember[] - messages Message[] - - @@map("conversations") -} - -enum MemberRole { - owner - admin - member -} - -model ConversationMember { - id String @id @default(uuid()) - conversationId String @map("conversation_id") - userId String @map("user_id") - role MemberRole @default(member) - joinedAt DateTime @default(now()) @map("joined_at") - lastSeenAt DateTime? @map("last_seen_at") - lastSeenMsgId String? @map("last_seen_msg_id") - - // Relations - conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - lastSeenMsg Message? @relation("LastSeenMessage", fields: [lastSeenMsgId], references: [id]) - - @@unique([conversationId, userId]) - @@index([userId]) - @@map("conversation_members") -} - -model Message { - id String @id @default(uuid()) - conversationId String @map("conversation_id") - senderId String @map("sender_id") - clientMsgId String @map("client_msg_id") - body String - createdAt DateTime @default(now()) @map("created_at") - - // Relations - conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) - sender User @relation(fields: [senderId], references: [id], onDelete: Cascade) - seenBy ConversationMember[] @relation("LastSeenMessage") - - // Unique constraint for idempotency - @@unique([senderId, clientMsgId]) - @@index([conversationId, createdAt]) - @@map("messages") -} diff --git a/apps/server/src/__tests__/basic.test.ts b/apps/server/src/__tests__/basic.test.ts deleted file mode 100644 index 625df13..0000000 --- a/apps/server/src/__tests__/basic.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -describe('Server', () => { - it('should pass basic test', () => { - expect(1 + 1).toBe(2); - }); -}); - -describe('Environment', () => { - it('should have NODE_ENV set', () => { - expect(process.env.NODE_ENV).toBeDefined(); - }); -}); diff --git a/apps/server/src/config/env.ts b/apps/server/src/config/env.ts deleted file mode 100644 index 79ee50c..0000000 --- a/apps/server/src/config/env.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from 'zod'; -import 'dotenv/config'; - -const envSchema = z.object({ - PORT: z.string().default('4000').transform(Number), - HOST: z.string().default('0.0.0.0'), - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), - DATABASE_URL: z.string().url(), - JWT_SECRET: z.string().min(32), - GOOGLE_CLIENT_ID: z.string(), - GOOGLE_CLIENT_SECRET: z.string(), - GOOGLE_CALLBACK_URL: z.string().url(), - FRONTEND_URL: z.string().url(), -}); - -const parsed = envSchema.safeParse(process.env); - -if (!parsed.success) { - console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors); - process.exit(1); -} - -export const env = parsed.data; -export type Env = z.infer; diff --git a/apps/server/src/config/index.ts b/apps/server/src/config/index.ts deleted file mode 100644 index 7915515..0000000 --- a/apps/server/src/config/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './env.js'; diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts deleted file mode 100644 index 389c690..0000000 --- a/apps/server/src/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import Fastify, { FastifyInstance } from 'fastify'; -import cors from '@fastify/cors'; -import jwt from '@fastify/jwt'; -import cookie from '@fastify/cookie'; -import { env } from './config/env.js'; -import { prisma } from './lib/prisma.js'; -import { authRoutes } from './routes/auth.js'; -import { userRoutes } from './routes/users.js'; -import { conversationRoutes } from './routes/conversations.js'; -import { setupSocketIO } from './socket/index.js'; - -export async function buildApp(): Promise { - const app = Fastify({ - logger: { - level: env.NODE_ENV === 'development' ? 'info' : 'warn', - transport: env.NODE_ENV === 'development' ? { - target: 'pino-pretty', - options: { colorize: true }, - } : undefined, - }, - }); - - // Register plugins - await app.register(cors, { - origin: env.FRONTEND_URL, - credentials: true, - }); - - await app.register(cookie, { - secret: env.JWT_SECRET, - parseOptions: {}, - }); - - await app.register(jwt, { - secret: env.JWT_SECRET, - cookie: { - cookieName: 'token', - signed: false, - }, - }); - - // Decorate request with user - app.decorateRequest('user', null); - - // Health check - app.get('/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() })); - - // Register routes - await app.register(authRoutes, { prefix: '/auth' }); - await app.register(userRoutes); - await app.register(conversationRoutes, { prefix: '/conversations' }); - - return app; -} - -async function main() { - const app = await buildApp(); - - // Setup Socket.IO - const io = setupSocketIO(app.server); - - // Graceful shutdown - const shutdown = async () => { - console.log('Shutting down...'); - await app.close(); - await prisma.$disconnect(); - io.close(); - process.exit(0); - }; - - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); - - try { - await app.listen({ port: env.PORT, host: env.HOST }); - console.log(`🚀 Server running at http://${env.HOST}:${env.PORT}`); - console.log(`📡 WebSocket server ready`); - } catch (err) { - app.log.error(err); - process.exit(1); - } -} - -main(); diff --git a/apps/server/src/lib/index.ts b/apps/server/src/lib/index.ts deleted file mode 100644 index f8003b1..0000000 --- a/apps/server/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './prisma.js'; diff --git a/apps/server/src/lib/prisma.ts b/apps/server/src/lib/prisma.ts deleted file mode 100644 index 084d9b8..0000000 --- a/apps/server/src/lib/prisma.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { PrismaClient } from '@prisma/client'; -import { env } from '../config/env.js'; - -declare global { - // eslint-disable-next-line no-var - var prisma: PrismaClient | undefined; -} - -export const prisma = global.prisma || new PrismaClient({ - log: env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], -}); - -if (env.NODE_ENV !== 'production') { - global.prisma = prisma; -} - -export type { PrismaClient } from '@prisma/client'; diff --git a/apps/server/src/middleware/auth.ts b/apps/server/src/middleware/auth.ts deleted file mode 100644 index d0cb8a5..0000000 --- a/apps/server/src/middleware/auth.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { FastifyRequest, FastifyReply } from 'fastify'; - -export async function authenticate(request: FastifyRequest, reply: FastifyReply) { - try { - await request.jwtVerify(); - } catch (err) { - reply.status(401).send({ error: 'Unauthorized', message: 'Invalid or missing token' }); - } -} - -export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply) { - try { - await request.jwtVerify(); - } catch { - // Token is optional, so we don't throw an error - request.user = null; - } -} diff --git a/apps/server/src/middleware/index.ts b/apps/server/src/middleware/index.ts deleted file mode 100644 index 0cc7659..0000000 --- a/apps/server/src/middleware/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './auth.js'; diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts deleted file mode 100644 index dc7bc84..0000000 --- a/apps/server/src/routes/auth.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { FastifyInstance } from 'fastify'; -import { env } from '../config/env.js'; -import { prisma } from '../lib/prisma.js'; - -interface GoogleTokenResponse { - access_token: string; - token_type: string; - expires_in: number; - id_token: string; -} - -interface GoogleUserInfo { - sub: string; - email: string; - name: string; - picture?: string; - email_verified: boolean; -} - -export async function authRoutes(app: FastifyInstance) { - // Initiate Google OAuth - app.get('/google', async (request, reply) => { - const redirectUri = env.GOOGLE_CALLBACK_URL; - const scope = encodeURIComponent('openid email profile'); - const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?` + - `client_id=${env.GOOGLE_CLIENT_ID}` + - `&redirect_uri=${encodeURIComponent(redirectUri)}` + - `&response_type=code` + - `&scope=${scope}` + - `&access_type=offline` + - `&prompt=consent`; - - return reply.redirect(googleAuthUrl); - }); - - // Google OAuth callback - app.get('/google/callback', async (request, reply) => { - const { code } = request.query as { code?: string }; - - if (!code) { - return reply.status(400).send({ error: 'Missing authorization code' }); - } - - try { - // Exchange code for tokens - const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - code, - client_id: env.GOOGLE_CLIENT_ID, - client_secret: env.GOOGLE_CLIENT_SECRET, - redirect_uri: env.GOOGLE_CALLBACK_URL, - grant_type: 'authorization_code', - }), - }); - - if (!tokenResponse.ok) { - const error = await tokenResponse.text(); - app.log.error('Token exchange failed:', error); - return reply.status(400).send({ error: 'Failed to exchange code for tokens' }); - } - - const tokens = await tokenResponse.json() as GoogleTokenResponse; - - // Get user info - const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { - headers: { Authorization: `Bearer ${tokens.access_token}` }, - }); - - if (!userInfoResponse.ok) { - return reply.status(400).send({ error: 'Failed to get user info' }); - } - - const googleUser = await userInfoResponse.json() as GoogleUserInfo; - - // Upsert user in database - const user = await prisma.user.upsert({ - where: { googleSub: googleUser.sub }, - update: { - email: googleUser.email, - name: googleUser.name, - avatarUrl: googleUser.picture || null, - }, - create: { - googleSub: googleUser.sub, - email: googleUser.email, - name: googleUser.name, - avatarUrl: googleUser.picture || null, - }, - }); - - // Generate JWT - const token = app.jwt.sign({ - id: user.id, - email: user.email, - name: user.name, - avatarUrl: user.avatarUrl, - }, { expiresIn: '7d' }); - - // Set cookie and redirect to frontend - reply.setCookie('token', token, { - httpOnly: true, - secure: env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 7 * 24 * 60 * 60, // 7 days - }); - - // Redirect to frontend with token in URL for client-side storage - return reply.redirect(`${env.FRONTEND_URL}/auth/callback?token=${token}`); - } catch (error) { - app.log.error('OAuth callback error:', error); - return reply.status(500).send({ error: 'Authentication failed' }); - } - }); - - // Logout - app.post('/logout', async (_request, reply) => { - reply.clearCookie('token', { path: '/' }); - return { success: true }; - }); - - // Verify token endpoint (for Socket.IO auth) - app.get('/verify', { - preHandler: async (request, reply) => { - try { - await request.jwtVerify(); - } catch { - return reply.status(401).send({ error: 'Unauthorized' }); - } - }, - }, async (request) => { - return { valid: true, user: request.user }; - }); -} diff --git a/apps/server/src/routes/conversations.ts b/apps/server/src/routes/conversations.ts deleted file mode 100644 index abb41be..0000000 --- a/apps/server/src/routes/conversations.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { FastifyInstance } from 'fastify'; -import { authenticate } from '../middleware/auth.js'; -import { prisma } from '../lib/prisma.js'; -import { - createDmRequestSchema, - createGroupRequestSchema, - addMemberRequestSchema, - messagesQuerySchema, -} from '../schemas/index.js'; - -export async function conversationRoutes(app: FastifyInstance) { - // List user's conversations - app.get('/', { - preHandler: authenticate, - }, async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ error: 'Unauthorized' }); - } - - const conversations = await prisma.conversation.findMany({ - where: { - members: { - some: { userId: request.user.id }, - }, - }, - include: { - members: { - include: { - user: { - select: { id: true, email: true, name: true, avatarUrl: true }, - }, - }, - }, - messages: { - orderBy: { createdAt: 'desc' }, - take: 1, - select: { - id: true, - body: true, - createdAt: true, - sender: { select: { id: true, name: true } }, - }, - }, - }, - orderBy: { updatedAt: 'desc' }, - }); - - // Transform response for DMs to show other user's name - const transformed = conversations.map((conv) => { - if (conv.type === 'dm') { - const otherMember = conv.members.find((m) => m.userId !== request.user!.id); - return { - ...conv, - displayName: otherMember?.user.name || 'Unknown', - displayAvatar: otherMember?.user.avatarUrl, - }; - } - return { - ...conv, - displayName: conv.title || 'Group', - displayAvatar: null, - }; - }); - - return transformed; - }); - - // Create DM conversation - app.post('/dm', { - preHandler: authenticate, - }, async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ error: 'Unauthorized' }); - } - - const parseResult = createDmRequestSchema.safeParse(request.body); - if (!parseResult.success) { - return reply.status(400).send({ error: 'Invalid request', details: parseResult.error.errors }); - } - - const { email } = parseResult.data; - - // Find target user - const targetUser = await prisma.user.findUnique({ - where: { email }, - select: { id: true, email: true, name: true, avatarUrl: true }, - }); - - if (!targetUser) { - return reply.status(404).send({ error: 'User not found' }); - } - - if (targetUser.id === request.user.id) { - return reply.status(400).send({ error: 'Cannot create DM with yourself' }); - } - - // Check if DM already exists between these users - const existingDm = await prisma.conversation.findFirst({ - where: { - type: 'dm', - AND: [ - { members: { some: { userId: request.user.id } } }, - { members: { some: { userId: targetUser.id } } }, - ], - }, - include: { - members: { - include: { - user: { select: { id: true, email: true, name: true, avatarUrl: true } }, - }, - }, - }, - }); - - if (existingDm) { - return existingDm; - } - - // Create new DM conversation - const conversation = await prisma.conversation.create({ - data: { - type: 'dm', - members: { - create: [ - { userId: request.user.id, role: 'member' }, - { userId: targetUser.id, role: 'member' }, - ], - }, - }, - include: { - members: { - include: { - user: { select: { id: true, email: true, name: true, avatarUrl: true } }, - }, - }, - }, - }); - - return conversation; - }); - - // Create group conversation - app.post('/group', { - preHandler: authenticate, - }, async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ error: 'Unauthorized' }); - } - - const parseResult = createGroupRequestSchema.safeParse(request.body); - if (!parseResult.success) { - return reply.status(400).send({ error: 'Invalid request', details: parseResult.error.errors }); - } - - const { title, memberEmails } = parseResult.data; - - // Find all target users - const targetUsers = await prisma.user.findMany({ - where: { email: { in: memberEmails } }, - select: { id: true, email: true, name: true, avatarUrl: true }, - }); - - if (targetUsers.length !== memberEmails.length) { - const foundEmails = targetUsers.map((u) => u.email); - const notFound = memberEmails.filter((e) => !foundEmails.includes(e)); - return reply.status(404).send({ error: 'Some users not found', notFound }); - } - - // Create group conversation with owner and members - const conversation = await prisma.conversation.create({ - data: { - type: 'group', - title, - members: { - create: [ - { userId: request.user.id, role: 'owner' }, - ...targetUsers.map((u) => ({ userId: u.id, role: 'member' as const })), - ], - }, - }, - include: { - members: { - include: { - user: { select: { id: true, email: true, name: true, avatarUrl: true } }, - }, - }, - }, - }); - - return conversation; - }); - - // Get single conversation - app.get('/:id', { - preHandler: authenticate, - }, async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ error: 'Unauthorized' }); - } - - const { id } = request.params as { id: string }; - - const conversation = await prisma.conversation.findFirst({ - where: { - id, - members: { some: { userId: request.user.id } }, - }, - include: { - members: { - include: { - user: { select: { id: true, email: true, name: true, avatarUrl: true } }, - }, - }, - }, - }); - - if (!conversation) { - return reply.status(404).send({ error: 'Conversation not found' }); - } - - return conversation; - }); - - // Add member to group - app.post('/:id/members', { - preHandler: authenticate, - }, async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ error: 'Unauthorized' }); - } - - const { id } = request.params as { id: string }; - const parseResult = addMemberRequestSchema.safeParse(request.body); - - if (!parseResult.success) { - return reply.status(400).send({ error: 'Invalid request', details: parseResult.error.errors }); - } - - const { email } = parseResult.data; - - // Check user is owner/admin of the group - const membership = await prisma.conversationMember.findFirst({ - where: { - conversationId: id, - userId: request.user.id, - role: { in: ['owner', 'admin'] }, - }, - include: { conversation: true }, - }); - - if (!membership) { - return reply.status(403).send({ error: 'Not authorized to add members' }); - } - - if (membership.conversation.type !== 'group') { - return reply.status(400).send({ error: 'Cannot add members to DM' }); - } - - // Find target user - const targetUser = await prisma.user.findUnique({ - where: { email }, - select: { id: true }, - }); - - if (!targetUser) { - return reply.status(404).send({ error: 'User not found' }); - } - - // Check if already a member - const existingMember = await prisma.conversationMember.findUnique({ - where: { - conversationId_userId: { - conversationId: id, - userId: targetUser.id, - }, - }, - }); - - if (existingMember) { - return reply.status(400).send({ error: 'User is already a member' }); - } - - // Add member - await prisma.conversationMember.create({ - data: { - conversationId: id, - userId: targetUser.id, - role: 'member', - }, - }); - - // Return updated conversation - const conversation = await prisma.conversation.findUnique({ - where: { id }, - include: { - members: { - include: { - user: { select: { id: true, email: true, name: true, avatarUrl: true } }, - }, - }, - }, - }); - - return conversation; - }); - - // Remove member from group - app.delete('/:id/members/:userId', { - preHandler: authenticate, - }, async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ error: 'Unauthorized' }); - } - - const { id, userId } = request.params as { id: string; userId: string }; - - // Check user is owner/admin or removing themselves - const membership = await prisma.conversationMember.findFirst({ - where: { - conversationId: id, - userId: request.user.id, - }, - include: { conversation: true }, - }); - - if (!membership) { - return reply.status(403).send({ error: 'Not a member of this conversation' }); - } - - if (membership.conversation.type !== 'group') { - return reply.status(400).send({ error: 'Cannot remove members from DM' }); - } - - const isAdmin = membership.role === 'owner' || membership.role === 'admin'; - const isSelf = userId === request.user.id; - - if (!isAdmin && !isSelf) { - return reply.status(403).send({ error: 'Not authorized to remove members' }); - } - - // Don't allow owner to be removed - const targetMembership = await prisma.conversationMember.findUnique({ - where: { - conversationId_userId: { - conversationId: id, - userId, - }, - }, - }); - - if (targetMembership?.role === 'owner') { - return reply.status(400).send({ error: 'Cannot remove the owner' }); - } - - await prisma.conversationMember.delete({ - where: { - conversationId_userId: { - conversationId: id, - userId, - }, - }, - }); - - return { success: true }; - }); - - // Get messages for conversation - app.get('/:id/messages', { - preHandler: authenticate, - }, async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ error: 'Unauthorized' }); - } - - const { id } = request.params as { id: string }; - const parseResult = messagesQuerySchema.safeParse(request.query); - - if (!parseResult.success) { - return reply.status(400).send({ error: 'Invalid query', details: parseResult.error.errors }); - } - - const { after, limit } = parseResult.data; - - // Check user is a member - const membership = await prisma.conversationMember.findUnique({ - where: { - conversationId_userId: { - conversationId: id, - userId: request.user.id, - }, - }, - }); - - if (!membership) { - return reply.status(403).send({ error: 'Not a member of this conversation' }); - } - - // Build query - const whereClause: { conversationId: string; createdAt?: { gt: Date } } = { - conversationId: id, - }; - - if (after) { - const afterMessage = await prisma.message.findUnique({ - where: { id: after }, - select: { createdAt: true }, - }); - - if (afterMessage) { - whereClause.createdAt = { gt: afterMessage.createdAt }; - } - } - - const messages = await prisma.message.findMany({ - where: whereClause, - include: { - sender: { - select: { id: true, name: true, avatarUrl: true }, - }, - }, - orderBy: { createdAt: 'asc' }, - take: limit, - }); - - // Update last seen - if (messages.length > 0) { - const lastMessage = messages[messages.length - 1]; - await prisma.conversationMember.update({ - where: { - conversationId_userId: { - conversationId: id, - userId: request.user.id, - }, - }, - data: { - lastSeenMsgId: lastMessage.id, - lastSeenAt: new Date(), - }, - }); - } - - return messages; - }); -} diff --git a/apps/server/src/routes/index.ts b/apps/server/src/routes/index.ts deleted file mode 100644 index 2b607d8..0000000 --- a/apps/server/src/routes/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './auth.js'; -export * from './users.js'; -export * from './conversations.js'; diff --git a/apps/server/src/routes/users.ts b/apps/server/src/routes/users.ts deleted file mode 100644 index 9721b35..0000000 --- a/apps/server/src/routes/users.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { FastifyInstance } from 'fastify'; -import { authenticate } from '../middleware/auth.js'; -import { prisma } from '../lib/prisma.js'; - -export async function userRoutes(app: FastifyInstance) { - // Get current user - app.get('/me', { - preHandler: authenticate, - }, async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ error: 'Unauthorized' }); - } - - const user = await prisma.user.findUnique({ - where: { id: request.user.id }, - select: { - id: true, - email: true, - name: true, - avatarUrl: true, - createdAt: true, - }, - }); - - if (!user) { - return reply.status(404).send({ error: 'User not found' }); - } - - return user; - }); - - // Search users by email (for adding to conversations) - app.get('/users/search', { - preHandler: authenticate, - }, async (request, reply) => { - const { email } = request.query as { email?: string }; - - if (!email || email.length < 3) { - return reply.status(400).send({ error: 'Email query must be at least 3 characters' }); - } - - const users = await prisma.user.findMany({ - where: { - email: { contains: email, mode: 'insensitive' }, - NOT: { id: request.user!.id }, - }, - select: { - id: true, - email: true, - name: true, - avatarUrl: true, - }, - take: 10, - }); - - return users; - }); -} diff --git a/apps/server/src/schemas/index.ts b/apps/server/src/schemas/index.ts deleted file mode 100644 index 246275e..0000000 --- a/apps/server/src/schemas/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { z } from 'zod'; - -// User schemas -export const userSchema = z.object({ - id: z.string().uuid(), - googleSub: z.string(), - email: z.string().email(), - name: z.string(), - avatarUrl: z.string().url().nullable(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type User = z.infer; - -// Conversation schemas -export const conversationTypeSchema = z.enum(['dm', 'group']); -export type ConversationType = z.infer; - -export const conversationSchema = z.object({ - id: z.string().uuid(), - type: conversationTypeSchema, - title: z.string().nullable(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type Conversation = z.infer; - -// Member schemas -export const memberRoleSchema = z.enum(['owner', 'admin', 'member']); -export type MemberRole = z.infer; - -export const conversationMemberSchema = z.object({ - id: z.string().uuid(), - conversationId: z.string().uuid(), - userId: z.string().uuid(), - role: memberRoleSchema, - joinedAt: z.date(), - lastSeenAt: z.date().nullable(), - lastSeenMsgId: z.string().uuid().nullable(), -}); - -export type ConversationMember = z.infer; - -// Message schemas -export const messageSchema = z.object({ - id: z.string().uuid(), - conversationId: z.string().uuid(), - senderId: z.string().uuid(), - clientMsgId: z.string().uuid(), - body: z.string(), - createdAt: z.date(), -}); - -export type Message = z.infer; - -// API Request/Response schemas -export const createDmRequestSchema = z.object({ - email: z.string().email(), -}); - -export const createGroupRequestSchema = z.object({ - title: z.string().min(1).max(100), - memberEmails: z.array(z.string().email()).min(1), -}); - -export const addMemberRequestSchema = z.object({ - email: z.string().email(), -}); - -export const messagesQuerySchema = z.object({ - after: z.string().uuid().optional(), - limit: z.coerce.number().int().min(1).max(100).default(50), -}); - -// WebSocket event schemas -export const wsMessageSendSchema = z.object({ - conversationId: z.string().uuid(), - clientMsgId: z.string().uuid(), - body: z.string().min(1).max(10000), -}); - -export const wsSyncRequestSchema = z.object({ - conversationId: z.string().uuid(), - afterMessageId: z.string().uuid().optional(), -}); - -export const wsAuthSchema = z.object({ - token: z.string(), -}); diff --git a/apps/server/src/socket/index.ts b/apps/server/src/socket/index.ts deleted file mode 100644 index a96aab4..0000000 --- a/apps/server/src/socket/index.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { Server as HttpServer } from 'http'; -import { Server, Socket } from 'socket.io'; -import jwt from 'jsonwebtoken'; -import { env } from '../config/env.js'; -import { prisma } from '../lib/prisma.js'; -import { wsMessageSendSchema, wsSyncRequestSchema } from '../schemas/index.js'; - -interface AuthenticatedSocket extends Socket { - userId: string; - userEmail: string; - userName: string; -} - -interface JWTPayload { - id: string; - email: string; - name: string; - avatarUrl: string | null; -} - -// Store online users: userId -> Set of socket IDs -const onlineUsers = new Map>(); - -export function setupSocketIO(httpServer: HttpServer): Server { - const io = new Server(httpServer, { - cors: { - origin: env.FRONTEND_URL, - credentials: true, - }, - pingTimeout: 60000, - pingInterval: 25000, - }); - - // Authentication middleware - io.use((socket, next) => { - const token = socket.handshake.auth.token || socket.handshake.headers.authorization?.split(' ')[1]; - - if (!token) { - return next(new Error('Authentication required')); - } - - try { - const payload = jwt.verify(token, env.JWT_SECRET) as JWTPayload; - (socket as AuthenticatedSocket).userId = payload.id; - (socket as AuthenticatedSocket).userEmail = payload.email; - (socket as AuthenticatedSocket).userName = payload.name; - next(); - } catch { - next(new Error('Invalid token')); - } - }); - - io.on('connection', async (socket: Socket) => { - const authSocket = socket as AuthenticatedSocket; - const userId = authSocket.userId; - - console.log(`User connected: ${authSocket.userName} (${userId})`); - - // Track online status - if (!onlineUsers.has(userId)) { - onlineUsers.set(userId, new Set()); - } - onlineUsers.get(userId)!.add(socket.id); - - // Join user to their conversation rooms - await joinUserConversations(authSocket); - - // Handle message send - socket.on('message:send', async (data, ack) => { - try { - const parseResult = wsMessageSendSchema.safeParse(data); - if (!parseResult.success) { - socket.emit('error', { message: 'Invalid message format', details: parseResult.error.errors }); - return; - } - - const { conversationId, clientMsgId, body } = parseResult.data; - - // Verify user is member of conversation - const membership = await prisma.conversationMember.findUnique({ - where: { - conversationId_userId: { - conversationId, - userId, - }, - }, - }); - - if (!membership) { - socket.emit('error', { message: 'Not a member of this conversation' }); - return; - } - - // Check for duplicate (idempotency) - const existingMessage = await prisma.message.findUnique({ - where: { - senderId_clientMsgId: { - senderId: userId, - clientMsgId, - }, - }, - }); - - if (existingMessage) { - // Return existing message (idempotent response) - const ackPayload = { - clientMsgId, - messageId: existingMessage.id, - createdAt: existingMessage.createdAt.toISOString(), - }; - if (typeof ack === 'function') { - ack(ackPayload); - } else { - socket.emit('message:ack', ackPayload); - } - return; - } - - // Create message - const message = await prisma.message.create({ - data: { - conversationId, - senderId: userId, - clientMsgId, - body, - }, - include: { - sender: { - select: { id: true, name: true, avatarUrl: true }, - }, - }, - }); - - // Update conversation timestamp - await prisma.conversation.update({ - where: { id: conversationId }, - data: { updatedAt: new Date() }, - }); - - // Acknowledge to sender - const ackPayload = { - clientMsgId, - messageId: message.id, - createdAt: message.createdAt.toISOString(), - }; - - if (typeof ack === 'function') { - ack(ackPayload); - } else { - socket.emit('message:ack', ackPayload); - } - - // Broadcast to all members in the conversation room (including sender for multi-device) - io.to(`conversation:${conversationId}`).emit('message:new', { - messageId: message.id, - conversationId, - senderId: message.senderId, - senderName: message.sender.name, - senderAvatar: message.sender.avatarUrl, - clientMsgId, - body: message.body, - createdAt: message.createdAt.toISOString(), - }); - - } catch (error) { - console.error('Error handling message:send:', error); - socket.emit('error', { message: 'Failed to send message' }); - } - }); - - // Handle sync request (offline catch-up) - socket.on('sync:request', async (data) => { - try { - const parseResult = wsSyncRequestSchema.safeParse(data); - if (!parseResult.success) { - socket.emit('error', { message: 'Invalid sync request', details: parseResult.error.errors }); - return; - } - - const { conversationId, afterMessageId } = parseResult.data; - - // Verify user is member of conversation - const membership = await prisma.conversationMember.findUnique({ - where: { - conversationId_userId: { - conversationId, - userId, - }, - }, - }); - - if (!membership) { - socket.emit('error', { message: 'Not a member of this conversation' }); - return; - } - - // Build query - const whereClause: { conversationId: string; createdAt?: { gt: Date } } = { - conversationId, - }; - - if (afterMessageId) { - const afterMessage = await prisma.message.findUnique({ - where: { id: afterMessageId }, - select: { createdAt: true }, - }); - - if (afterMessage) { - whereClause.createdAt = { gt: afterMessage.createdAt }; - } - } - - const messages = await prisma.message.findMany({ - where: whereClause, - include: { - sender: { - select: { id: true, name: true, avatarUrl: true }, - }, - }, - orderBy: { createdAt: 'asc' }, - take: 100, // Limit sync batch - }); - - socket.emit('sync:batch', { - conversationId, - messages: messages.map((m) => ({ - messageId: m.id, - conversationId: m.conversationId, - senderId: m.senderId, - senderName: m.sender.name, - senderAvatar: m.sender.avatarUrl, - clientMsgId: m.clientMsgId, - body: m.body, - createdAt: m.createdAt.toISOString(), - })), - }); - - // Update last seen - if (messages.length > 0) { - const lastMessage = messages[messages.length - 1]; - await prisma.conversationMember.update({ - where: { - conversationId_userId: { - conversationId, - userId, - }, - }, - data: { - lastSeenMsgId: lastMessage.id, - lastSeenAt: new Date(), - }, - }); - } - - } catch (error) { - console.error('Error handling sync:request:', error); - socket.emit('error', { message: 'Failed to sync messages' }); - } - }); - - // Handle typing indicator - socket.on('typing:start', async (data: { conversationId: string }) => { - socket.to(`conversation:${data.conversationId}`).emit('typing:start', { - conversationId: data.conversationId, - userId, - userName: authSocket.userName, - }); - }); - - socket.on('typing:stop', async (data: { conversationId: string }) => { - socket.to(`conversation:${data.conversationId}`).emit('typing:stop', { - conversationId: data.conversationId, - userId, - }); - }); - - // Handle disconnect - socket.on('disconnect', () => { - console.log(`User disconnected: ${authSocket.userName} (${userId})`); - - const userSockets = onlineUsers.get(userId); - if (userSockets) { - userSockets.delete(socket.id); - if (userSockets.size === 0) { - onlineUsers.delete(userId); - } - } - }); - }); - - return io; -} - -async function joinUserConversations(socket: AuthenticatedSocket) { - const memberships = await prisma.conversationMember.findMany({ - where: { userId: socket.userId }, - select: { conversationId: true }, - }); - - for (const membership of memberships) { - socket.join(`conversation:${membership.conversationId}`); - } - - console.log(`User ${socket.userName} joined ${memberships.length} conversation rooms`); -} - -export function isUserOnline(userId: string): boolean { - return onlineUsers.has(userId) && onlineUsers.get(userId)!.size > 0; -} - -export function getOnlineUsers(): string[] { - return Array.from(onlineUsers.keys()); -} diff --git a/apps/server/src/types/fastify.ts b/apps/server/src/types/fastify.ts deleted file mode 100644 index 64c59d9..0000000 --- a/apps/server/src/types/fastify.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; - -declare module 'fastify' { - interface FastifyRequest { - user: { - id: string; - email: string; - name: string; - avatarUrl: string | null; - } | null; - } -} - -declare module '@fastify/jwt' { - interface FastifyJWT { - payload: { - id: string; - email: string; - name: string; - avatarUrl: string | null; - }; - user: { - id: string; - email: string; - name: string; - avatarUrl: string | null; - }; - } -} - -export type { FastifyInstance, FastifyRequest, FastifyReply }; diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts deleted file mode 100644 index 40c8129..0000000 --- a/apps/server/vitest.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - environment: 'node', - include: ['src/**/*.test.ts'], - coverage: { - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/'], - }, - }, -}); diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore deleted file mode 100644 index ba2800b..0000000 --- a/apps/web/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -.next -.git -*.log diff --git a/apps/web/.env.example b/apps/web/.env.example deleted file mode 100644 index e7c7363..0000000 --- a/apps/web/.env.example +++ /dev/null @@ -1,7 +0,0 @@ -# Next.js Configuration -NEXT_PUBLIC_API_URL=http://localhost:4000 -NEXT_PUBLIC_WS_URL=http://localhost:4000 - -# NextAuth.js -NEXTAUTH_URL=http://localhost:3000 -NEXTAUTH_SECRET=your-nextauth-secret-change-this-in-production diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json deleted file mode 100644 index bffb357..0000000 --- a/apps/web/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/apps/web/.gitignore b/apps/web/.gitignore deleted file mode 100644 index 2a7ad13..0000000 --- a/apps/web/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Dependencies -node_modules/ -.pnp -.pnp.js - -# Build outputs -.next/ -out/ -build/ - -# Environment files -.env -.env.local -.env.*.local - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# Logs -logs/ -*.log -npm-debug.log* - -# Misc -*.tgz -.cache/ diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile deleted file mode 100644 index da8ffc8..0000000 --- a/apps/web/Dockerfile +++ /dev/null @@ -1,51 +0,0 @@ -# Build stage -FROM node:20-alpine AS builder - -WORKDIR /app - -# Copy package files -COPY package.json ./ - -# Install dependencies -RUN npm install - -# Copy source code -COPY . . - -# Build arguments for environment variables -ARG NEXT_PUBLIC_API_URL -ARG NEXT_PUBLIC_WS_URL - -ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL -ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL - -# Build the application -RUN npm run build - -# Production stage -FROM node:20-alpine AS runner - -WORKDIR /app - -ENV NODE_ENV=production - -# Create non-root user -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs - -# Copy built files -COPY --from=builder /app/public ./public -COPY --from=builder /app/.next/standalone ./ -COPY --from=builder /app/.next/static ./.next/static - -# Set ownership -RUN chown -R nextjs:nodejs /app - -USER nextjs - -EXPOSE 3000 - -ENV PORT=3000 -ENV HOSTNAME="0.0.0.0" - -CMD ["node", "server.js"] diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts old mode 100644 new mode 100755 index 4f11a03..4eb833e --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,5 +1,5 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs old mode 100644 new mode 100755 index 4fb095b..f7a7015 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,20 +1,9 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - output: 'standalone', - images: { - remotePatterns: [ - { - protocol: 'https', - hostname: 'lh3.googleusercontent.com', - pathname: '/**', - }, - ], - }, - experimental: { - serverActions: { - bodySizeLimit: '2mb', - }, - }, -}; - -export default nextConfig; +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ["@chat/types", "@chat/ui"], + images: { + domains: ["lh3.googleusercontent.com"], + }, +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json old mode 100644 new mode 100755 index 862dc6b..640d328 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,35 +1,41 @@ -{ - "name": "@oschat/web", - "version": "1.0.0", - "description": "OSChat Frontend - Next.js", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "next": "14.2.5", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "socket.io-client": "^4.7.5", - "zod": "^3.23.8", - "zustand": "^4.5.4", - "lucide-react": "^0.411.0", - "clsx": "^2.1.1", - "tailwind-merge": "^2.4.0" - }, - "devDependencies": { - "@types/node": "^20.14.10", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "autoprefixer": "^10.4.19", - "eslint": "^8.57.0", - "eslint-config-next": "14.2.5", - "postcss": "^8.4.39", - "tailwindcss": "^3.4.6", - "typescript": "^5.5.3" - } -} +{ + "name": "web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "^14.2.21", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "socket.io-client": "^4.8.1", + "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-toast": "^1.2.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.469.0", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7", + "date-fns": "^4.1.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/node": "^22", + "@types/react": "^18", + "@types/react-dom": "^18", + "autoprefixer": "^10.4.20", + "eslint": "^8", + "eslint-config-next": "14.2.21", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js deleted file mode 100644 index 12a703d..0000000 --- a/apps/web/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100755 index 0000000..7455d3e --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; diff --git a/apps/web/src/app/auth/callback/page.tsx b/apps/web/src/app/auth/callback/page.tsx old mode 100644 new mode 100755 index 9c0e122..de34850 --- a/apps/web/src/app/auth/callback/page.tsx +++ b/apps/web/src/app/auth/callback/page.tsx @@ -1,53 +1,25 @@ -'use client'; - -import { useEffect, Suspense } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { useAuthStore } from '@/stores/auth'; -import { Loader2 } from 'lucide-react'; - -function CallbackContent() { - const router = useRouter(); - const searchParams = useSearchParams(); - const { setToken, fetchUser } = useAuthStore(); - - useEffect(() => { - const token = searchParams.get('token'); - - if (token) { - setToken(token); - fetchUser().then(() => { - router.push('/chat'); - }).catch(() => { - router.push('/?error=auth_failed'); - }); - } else { - router.push('/?error=no_token'); - } - }, [searchParams, setToken, fetchUser, router]); - - return ( -
    -
    - -

    Signing you in...

    -
    -
    - ); -} - -export default function AuthCallback() { - return ( - -
    - -

    Loading...

    -
    -
    - } - > - - - ); -} +"use client"; + +import { useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +export default function AuthCallback() { + const router = useRouter(); + const searchParams = useSearchParams(); + + useEffect(() => { + const token = searchParams.get("token"); + if (token) { + localStorage.setItem("token", token); + router.push("/chat"); + } else { + router.push("/login"); + } + }, [searchParams, router]); + + return ( +
    +
    +
    + ); +} diff --git a/apps/web/src/app/chat/page.tsx b/apps/web/src/app/chat/page.tsx old mode 100644 new mode 100755 index 49893b8..977e59d --- a/apps/web/src/app/chat/page.tsx +++ b/apps/web/src/app/chat/page.tsx @@ -1,99 +1,34 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { Menu, X } from 'lucide-react'; -import { useAuthStore } from '@/stores/auth'; -import { useConversationStore } from '@/stores/conversations'; -import { ConversationList, MessageArea, SocketProvider } from '@/components/chat'; -import { Spinner } from '@/components/ui'; -import { cn } from '@/lib/utils'; - -export default function ChatPage() { - const router = useRouter(); - const { isAuthenticated, isLoading, token, fetchUser } = useAuthStore(); - const { fetchConversations } = useConversationStore(); - const [sidebarOpen, setSidebarOpen] = useState(false); - - // Check auth on mount - useEffect(() => { - if (token && !isAuthenticated) { - fetchUser(); - } - }, [token, isAuthenticated, fetchUser]); - - // Redirect if not authenticated - useEffect(() => { - if (!isLoading && !isAuthenticated && !token) { - router.push('/'); - } - }, [isLoading, isAuthenticated, token, router]); - - // Fetch conversations when authenticated - useEffect(() => { - if (isAuthenticated) { - fetchConversations(); - } - }, [isAuthenticated, fetchConversations]); - - if (isLoading || (!isAuthenticated && token)) { - return ( -
    - -
    - ); - } - - if (!isAuthenticated) { - return null; - } - - return ( - -
    - {/* Mobile sidebar backdrop */} - {sidebarOpen && ( -
    setSidebarOpen(false)} - /> - )} - - {/* Sidebar */} - - - {/* Main content */} -
    - {/* Mobile header */} -
    - - OSChat -
    - - -
    -
    - - ); -} +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ChatLayout } from "@/components/chat/ChatLayout"; +import { SocketProvider } from "@/lib/socket"; + +export default function ChatPage() { + const router = useRouter(); + const [isAuthenticated, setIsAuthenticated] = useState(false); + + useEffect(() => { + const token = localStorage.getItem("token"); + if (!token) { + router.push("/login"); + } else { + setIsAuthenticated(true); + } + }, [router]); + + if (!isAuthenticated) { + return ( +
    +
    +
    + ); + } + + return ( + + + + ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css old mode 100644 new mode 100755 index 9c0d1b5..95e9efa --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,49 +1,61 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - html { - @apply h-full; - } - body { - @apply h-full bg-gray-50 text-gray-900 antialiased; - } -} - -@layer components { - .btn { - @apply inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed; - } - .btn-primary { - @apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500; - } - .btn-secondary { - @apply btn bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-primary-500; - } - .btn-ghost { - @apply btn text-gray-600 hover:bg-gray-100 focus:ring-primary-500; - } - .input { - @apply w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent; - } -} - -/* Custom scrollbar */ -@layer utilities { - .scrollbar-thin { - scrollbar-width: thin; - } - .scrollbar-thin::-webkit-scrollbar { - width: 6px; - } - .scrollbar-thin::-webkit-scrollbar-track { - @apply bg-gray-100; - } - .scrollbar-thin::-webkit-scrollbar-thumb { - @apply bg-gray-300 rounded-full; - } - .scrollbar-thin::-webkit-scrollbar-thumb:hover { - @apply bg-gray-400; - } -} +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + } +} + +@layer base { + * { + @apply border-border; + } + html, body { + @apply bg-background text-foreground; + height: 100%; + overflow: hidden; + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx old mode 100644 new mode 100755 index 937a502..128b92e --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,22 +1,22 @@ -import type { Metadata } from 'next'; -import { Inter } from 'next/font/google'; -import './globals.css'; - -const inter = Inter({ subsets: ['latin'] }); - -export const metadata: Metadata = { - title: 'OSChat - Real-time Chat', - description: 'A modern real-time chat application', -}; - -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - {children} - - ); -} +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Chat App", + description: "Real-time chat application", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx new file mode 100755 index 0000000..86cbd85 --- /dev/null +++ b/apps/web/src/app/login/page.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Button } from "@chat/ui"; + +export default function Login() { + const handleGoogleLogin = () => { + window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/auth/google`; + }; + + return ( +
    +
    +
    +

    Chat App

    +

    Connect with friends in real-time

    +
    + + + +

    + By continuing, you agree to our Terms of Service and Privacy Policy +

    +
    +
    + ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx old mode 100644 new mode 100755 index c889609..3779229 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,128 +1,24 @@ -import Link from 'next/link'; -import { MessageSquare, Shield, Zap, Users } from 'lucide-react'; - -export default function Home() { - return ( -
    - {/* Header */} -
    - -
    - - {/* Hero */} -
    -
    -
    -
    -

    - Real-time chat for everyone -

    -

    - Connect with friends and colleagues instantly. Create group conversations, - send direct messages, and stay in sync across all your devices. -

    -
    - - Get started - - - Learn more - -
    -
    -
    -
    - - {/* Features */} -
    -
    -
    -

    - Features -

    -

    - Everything you need to stay connected -

    -
    -
    -
    -
    -
    -
    - -
    - Real-time messaging -
    -
    - Messages are delivered instantly using WebSocket technology. - No refresh needed. -
    -
    -
    -
    -
    - -
    - Group conversations -
    -
    - Create groups with multiple members. Add or remove members as needed. -
    -
    -
    -
    -
    - -
    - Secure authentication -
    -
    - Sign in securely with your Google account. Your data is protected. -
    -
    -
    -
    -
    - -
    - Offline sync -
    -
    - Never miss a message. Catch up on everything when you reconnect. -
    -
    -
    -
    -
    -
    -
    - - {/* Footer */} -
    -
    -

    - © {new Date().getFullYear()} OSChat. All rights reserved. -

    -
    -
    -
    - ); -} +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export default function Home() { + const router = useRouter(); + + useEffect(() => { + // Check if user is authenticated + const token = localStorage.getItem("token"); + if (token) { + router.push("/chat"); + } else { + router.push("/login"); + } + }, [router]); + + return ( +
    +
    +
    + ); +} diff --git a/apps/web/src/components/chat/ChatLayout.tsx b/apps/web/src/components/chat/ChatLayout.tsx new file mode 100755 index 0000000..a36d256 --- /dev/null +++ b/apps/web/src/components/chat/ChatLayout.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useChatStore } from "@/lib/store"; +import { api } from "@/lib/api"; +import { ConversationList } from "./ConversationList"; +import { ChatWindow } from "./ChatWindow"; +import { GroupCreationPanel } from "./GroupCreationPanel"; +import { UserSearch } from "./UserSearch"; +import { Button, Avatar, AvatarImage, AvatarFallback } from "@chat/ui"; +import { LogOut, Plus } from "lucide-react"; +import { useRouter } from "next/navigation"; + +type SidebarView = "conversations" | "new-chat"; + +export function ChatLayout() { + const router = useRouter(); + const [sidebarView, setSidebarView] = useState("conversations"); + const { user, setUser, conversations, setConversations, selectedConversationId, addConversation, setSelectedConversationId } = useChatStore(); + + useEffect(() => { + loadUserAndConversations(); + }, []); + + const loadUserAndConversations = async () => { + try { + const userData = await api.auth.getMe(); + setUser(userData); + + const conversationsData = await api.conversations.getAll(); + setConversations(conversationsData); + } catch (error) { + console.error("Failed to load data:", error); + router.push("/login"); + } + }; + + const handleLogout = () => { + localStorage.removeItem("token"); + router.push("/login"); + }; + + const handleStartChat = async (userId: string) => { + try { + const conversation = await api.conversations.create({ + type: "direct", + memberIds: [userId], + }); + + // Check if conversation already exists in list + const exists = conversations.find(c => c.id === conversation.id); + if (!exists) { + addConversation(conversation); + } + setSelectedConversationId(conversation.id); + setSidebarView("conversations"); // Return to conversations after starting chat + } catch (error) { + console.error("Failed to create conversation:", error); + } + }; + + return ( +
    + {/* Sidebar */} +
    + {sidebarView === "new-chat" ? ( + setSidebarView("conversations")} + onStartDirectChat={handleStartChat} + /> + ) : ( + <> + {/* Header */} +
    +
    +

    Messages

    + +
    + + {/* Search Users */} + +
    + + {/* Conversation List */} + + + {/* User Info - Bottom */} +
    +
    +
    + + + {user?.name?.[0] || "U"} + +
    +

    {user?.name}

    +

    Signed in

    +
    +
    + +
    +
    + + )} +
    + + {/* Main Chat Area */} +
    + {selectedConversationId ? ( + + ) : ( +
    +
    +
    💬
    +

    Welcome to Chat

    +

    Select a conversation to start messaging

    +
    +
    + )} +
    +
    + ); +} diff --git a/apps/web/src/components/chat/ChatWindow.tsx b/apps/web/src/components/chat/ChatWindow.tsx new file mode 100755 index 0000000..94b7bb0 --- /dev/null +++ b/apps/web/src/components/chat/ChatWindow.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useChatStore } from "@/lib/store"; +import { useSocket } from "@/lib/socket"; +import { api } from "@/lib/api"; +import { ScrollArea, Avatar, AvatarFallback, AvatarImage, Input, Button } from "@chat/ui"; +import { Send, X } from "lucide-react"; +import { MessageBubble } from "./MessageBubble"; +import type { Message } from "@chat/types"; + +export function ChatWindow() { + const { selectedConversationId, conversations, user, messages, setMessages, setSelectedConversationId } = useChatStore(); + const { socket } = useSocket(); + const [newMessage, setNewMessage] = useState(""); + const [isTyping, setIsTyping] = useState(false); + const messagesEndRef = useRef(null); + const typingTimeoutRef = useRef(); + + const conversation = conversations.find((c) => c.id === selectedConversationId); + const conversationMessages = selectedConversationId ? messages[selectedConversationId] || [] : []; + + useEffect(() => { + if (selectedConversationId && socket) { + loadMessages(); + socket.emit("join:conversation", selectedConversationId); + + return () => { + socket.emit("leave:conversation", selectedConversationId); + }; + } + }, [selectedConversationId, socket]); + + useEffect(() => { + scrollToBottom(); + }, [conversationMessages]); + + const loadMessages = async () => { + if (!selectedConversationId) return; + + try { + const msgs: Message[] = await api.messages.getByConversation(selectedConversationId); + setMessages(selectedConversationId, msgs); + } catch (error) { + console.error("Failed to load messages:", error); + } + }; + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + const handleSendMessage = (e: React.FormEvent) => { + e.preventDefault(); + + if (!newMessage.trim() || !selectedConversationId || !socket) return; + + socket.emit("message:send", { + conversationId: selectedConversationId, + content: newMessage.trim(), + }); + + setNewMessage(""); + handleStopTyping(); + }; + + const handleTyping = () => { + if (!socket || !selectedConversationId || !user) return; + + if (!isTyping) { + setIsTyping(true); + socket.emit("typing:start", { + conversationId: selectedConversationId, + userName: user.name, + }); + } + + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + + typingTimeoutRef.current = setTimeout(() => { + handleStopTyping(); + }, 2000); + }; + + const handleStopTyping = () => { + if (socket && selectedConversationId && isTyping) { + socket.emit("typing:stop", selectedConversationId); + setIsTyping(false); + } + }; + + const displayName = () => { + if (!conversation) return "Chat"; + + if (conversation.type === "group") { + return conversation.name || "Group Chat"; + } + + const otherUser = conversation.members.find((m) => m.userId !== user?.id)?.user; + return otherUser?.name || "Chat"; + }; + + const displayAvatar = () => { + if (!conversation) return null; + + if (conversation.type === "group") { + return {conversation.name?.[0] || "G"}; + } + + const otherUser = conversation.members.find((m) => m.userId !== user?.id)?.user; + return ( + <> + + {otherUser?.name?.[0] || "U"} + + ); + }; + + if (!conversation) return null; + + const handleCloseChat = () => { + setSelectedConversationId(null); + }; + + return ( +
    + {/* Header */} +
    +
    +
    + + {displayAvatar()} + +
    +

    {displayName()}

    + {conversation.type === "group" && ( +

    + {conversation.members.length} members +

    + )} +
    +
    + +
    +
    + + {/* Messages */} + +
    + {conversationMessages.map((message) => ( + + ))} +
    +
    + + + {/* Input */} +
    + + { + setNewMessage(e.target.value); + handleTyping(); + }} + placeholder="Type a message..." + className="flex-1" + /> + + +
    +
    + ); +} diff --git a/apps/web/src/components/chat/ConversationItem.tsx b/apps/web/src/components/chat/ConversationItem.tsx new file mode 100755 index 0000000..1c81b5b --- /dev/null +++ b/apps/web/src/components/chat/ConversationItem.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useMemo } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@chat/ui"; +import { useChatStore } from "@/lib/store"; +import type { Conversation } from "@chat/types"; +import { formatDistanceToNow } from "date-fns"; + +interface ConversationItemProps { + conversation: Conversation; + isSelected: boolean; + unreadCount: number; + onClick: () => void; +} + +export function ConversationItem({ conversation, isSelected, unreadCount, onClick }: ConversationItemProps) { + const { user } = useChatStore(); + + const displayInfo = useMemo(() => { + if (conversation.type === "group") { + return { + name: conversation.name || "Unnamed Group", + avatar: null, + initials: conversation.name?.[0] || "G", + }; + } + + // For direct messages, show the other user + const otherUser = conversation.members.find((m) => m.userId !== user?.id)?.user; + return { + name: otherUser?.name || "Unknown User", + avatar: otherUser?.avatar, + initials: otherUser?.name?.[0] || "U", + }; + }, [conversation, user]); + + const lastMessage = conversation.messages?.[0]; + const lastMessageTime = lastMessage + ? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: true }) + : ""; + + return ( + + ); +} diff --git a/apps/web/src/components/chat/ConversationList.tsx b/apps/web/src/components/chat/ConversationList.tsx old mode 100644 new mode 100755 index bf8e3e4..44d124b --- a/apps/web/src/components/chat/ConversationList.tsx +++ b/apps/web/src/components/chat/ConversationList.tsx @@ -1,327 +1,37 @@ -'use client'; - -import { useState } from 'react'; -import { Plus, MessageSquare, Users, Search, LogOut, X } from 'lucide-react'; -import { useAuthStore } from '@/stores/auth'; -import { useConversationStore, Conversation } from '@/stores/conversations'; -import { Avatar, Spinner } from '@/components/ui'; -import { cn, formatDate } from '@/lib/utils'; - -interface ConversationListProps { - onClose?: () => void; -} - -export function ConversationList({ onClose }: ConversationListProps) { - const { user, logout } = useAuthStore(); - const { - conversations, - currentConversationId, - isLoading, - setCurrentConversation, - } = useConversationStore(); - - const [showNewConversation, setShowNewConversation] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - - const filteredConversations = conversations.filter((conv) => - conv.displayName.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - const handleSelectConversation = (id: string) => { - setCurrentConversation(id); - onClose?.(); - }; - - return ( -
    - {/* Header */} -
    -
    -
    - -
    -

    {user?.name}

    -

    {user?.email}

    -
    -
    - -
    - - {/* Search */} -
    - - setSearchQuery(e.target.value)} - className="w-full pl-9 pr-3 py-2 text-sm bg-gray-100 border-0 rounded-lg focus:ring-2 focus:ring-primary-500" - /> -
    -
    - - {/* New Conversation Button */} -
    - -
    - - {/* Conversation List */} -
    - {isLoading ? ( -
    - -
    - ) : filteredConversations.length === 0 ? ( -
    - -

    - {searchQuery ? 'No conversations found' : 'No conversations yet'} -

    - {!searchQuery && ( - - )} -
    - ) : ( -
      - {filteredConversations.map((conv) => ( - handleSelectConversation(conv.id)} - /> - ))} -
    - )} -
    - - {/* New Conversation Modal */} - {showNewConversation && ( - setShowNewConversation(false)} /> - )} -
    - ); -} - -interface ConversationItemProps { - conversation: Conversation; - isActive: boolean; - onClick: () => void; -} - -function ConversationItem({ conversation, isActive, onClick }: ConversationItemProps) { - const lastMessage = conversation.messages?.[0]; - - return ( -
  • - -
  • - ); -} - -interface NewConversationModalProps { - onClose: () => void; -} - -function NewConversationModal({ onClose }: NewConversationModalProps) { - const [mode, setMode] = useState<'dm' | 'group'>('dm'); - const [email, setEmail] = useState(''); - const [groupTitle, setGroupTitle] = useState(''); - const [memberEmails, setMemberEmails] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(''); - - const { createDm, createGroup } = useConversationStore(); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - setIsLoading(true); - - try { - if (mode === 'dm') { - await createDm(email.trim()); - } else { - const emails = memberEmails.split(',').map((e) => e.trim()).filter(Boolean); - if (emails.length === 0) { - setError('Please enter at least one member email'); - setIsLoading(false); - return; - } - await createGroup(groupTitle.trim(), emails); - } - onClose(); - } catch (err) { - setError((err as Error).message); - } finally { - setIsLoading(false); - } - }; - - return ( -
    -
    -
    -

    New Conversation

    - -
    - -
    - {/* Mode Toggle */} -
    - - -
    - -
    - {mode === 'dm' ? ( -
    - - setEmail(e.target.value)} - placeholder="user@example.com" - className="input" - required - /> -
    - ) : ( - <> -
    - - setGroupTitle(e.target.value)} - placeholder="My Group" - className="input" - required - /> -
    -
    - -