diff --git a/.gitignore b/.gitignore index db9a47d..a1e6a19 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ build .env .env.* +# storage + +gcp-key.json + # editor files .vscode/* !.vscode/extensions.json diff --git a/backend/package-lock.json b/backend/package-lock.json index a29335f..e512562 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,7 @@ "name": "backend", "version": "1.0.0", "dependencies": { + "@google-cloud/storage": "^7.19.0", "@google/generative-ai": "^0.24.1", "@prisma/client": "^5.22.0", "@socket.io/redis-adapter": "^8.3.0", @@ -594,6 +595,75 @@ "tslib": "^2.4.0" } }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@google/generative-ai": { "version": "0.24.1", "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", @@ -1484,6 +1554,15 @@ } } }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -1589,6 +1668,12 @@ "@types/node": "*" } }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT" + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -1733,6 +1818,56 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/request/node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/@types/request/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@types/request/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -1799,6 +1934,12 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2101,6 +2242,18 @@ "win32" ] }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -2140,6 +2293,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -2213,6 +2375,15 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -2220,6 +2391,15 @@ "dev": true, "license": "MIT" }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2343,6 +2523,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -2379,6 +2579,15 @@ "node": ">= 18" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2965,6 +3174,18 @@ "node": ">= 0.4" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, "node_modules/dynamic-dedupe": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", @@ -3033,6 +3254,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/engine.io": { "version": "6.6.6", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz", @@ -3210,6 +3440,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3323,6 +3562,12 @@ "express": ">= 4.11" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3337,6 +3582,41 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.10", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.10.tgz", + "integrity": "sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.1", + "strnum": "^2.2.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3548,6 +3828,49 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3663,6 +3986,32 @@ "node": ">= 6" } }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3682,6 +4031,19 @@ "dev": true, "license": "ISC" }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -3762,6 +4124,22 @@ "node": ">=18.0.0" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3789,6 +4167,45 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -3980,7 +4397,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4917,6 +5333,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -5322,6 +5747,26 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -5438,7 +5883,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -5534,6 +5978,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.4.0.tgz", + "integrity": "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -5767,6 +6226,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -5850,6 +6323,29 @@ "node": ">=8" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", @@ -6213,6 +6709,30 @@ "node": ">= 0.8" } }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -6315,6 +6835,24 @@ "node": ">=0.10.0" } }, + "node_modules/strnum": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT" + }, "node_modules/superagent": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", @@ -6393,6 +6931,60 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -6437,6 +7029,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -6796,6 +7394,21 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -6848,6 +7461,22 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7032,7 +7661,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/backend/package.json b/backend/package.json index 7a96089..63b6e6d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "prisma:generate": "prisma generate" }, "dependencies": { + "@google-cloud/storage": "^7.19.0", "@google/generative-ai": "^0.24.1", "@prisma/client": "^5.22.0", "@socket.io/redis-adapter": "^8.3.0", diff --git a/backend/prisma/migrations/20260408134532_add_code_url/migration.sql b/backend/prisma/migrations/20260408134532_add_code_url/migration.sql new file mode 100644 index 0000000..729fef6 --- /dev/null +++ b/backend/prisma/migrations/20260408134532_add_code_url/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `code` on the `Room` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Room" DROP COLUMN "code", +ADD COLUMN "codeUrl" TEXT; diff --git a/backend/prisma/migrations/20260408143328_add_code_url/migration.sql b/backend/prisma/migrations/20260408143328_add_code_url/migration.sql new file mode 100644 index 0000000..a6a18f1 --- /dev/null +++ b/backend/prisma/migrations/20260408143328_add_code_url/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `code` to the `Room` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Room" ADD COLUMN "code" TEXT NOT NULL; diff --git a/backend/prisma/migrations/20260409153242_add_user_ai/migration.sql b/backend/prisma/migrations/20260409153242_add_user_ai/migration.sql new file mode 100644 index 0000000..37c3980 --- /dev/null +++ b/backend/prisma/migrations/20260409153242_add_user_ai/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - Added the required column `userId` to the `AIMessage` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "AIMessage" DROP CONSTRAINT "AIMessage_roomId_fkey"; + +-- DropIndex +DROP INDEX "AIMessage_roomId_idx"; + +-- AlterTable +ALTER TABLE "AIMessage" ADD COLUMN "userId" TEXT NOT NULL, +ALTER COLUMN "roomId" DROP NOT NULL; + +-- CreateIndex +CREATE INDEX "AIMessage_userId_idx" ON "AIMessage"("userId"); + +-- AddForeignKey +ALTER TABLE "AIMessage" ADD CONSTRAINT "AIMessage_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260411111221_add_code_hash/migration.sql b/backend/prisma/migrations/20260411111221_add_code_hash/migration.sql new file mode 100644 index 0000000..6e127cf --- /dev/null +++ b/backend/prisma/migrations/20260411111221_add_code_hash/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "CodeSnapshot" ADD COLUMN "codeHash" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 6e5472a..f8dbd53 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -19,7 +19,10 @@ model User { model Room { id String @id @default(uuid()) language String - code String @db.Text + code String @db.Text + + codeUrl String? + createdAt DateTime @default(now()) userId String @@ -40,19 +43,24 @@ model CodeSnapshot { language String createdAt DateTime @default(now()) + + codeHash String? @@index([roomId]) } model AIMessage { id String @id @default(uuid()) - roomId String - room Room @relation(fields: [roomId], references: [id]) + + userId String + roomId String? + + room Room? @relation(fields: [roomId], references: [id]) role String // "user" | "ai" content String @db.Text createdAt DateTime @default(now()) - @@index([roomId]) + @@index([userId]) } \ No newline at end of file diff --git a/backend/src/__tests__/app.test.ts b/backend/src/__tests__/app.test.ts index 56c235c..053a056 100644 --- a/backend/src/__tests__/app.test.ts +++ b/backend/src/__tests__/app.test.ts @@ -19,7 +19,7 @@ jest.mock("../db/prisma", () => ({ })); jest.mock("../services/ai.service", () => ({ - generativeAIResponse: jest.fn(), + streamAIResponse: jest.fn(), })); jest.mock("../services/judge0.service", () => ({ @@ -32,6 +32,7 @@ jest.mock("../services/room.service", () => ({ import request from "supertest"; import app from "../app"; +import { streamAIResponse } from "../services/ai.service"; // ── GET /health ─────────────────────────────────────────────────────────────── @@ -115,8 +116,8 @@ describe("Rate limiting", () => { }); it("returns 429 with correct message when AI rate limit is exceeded", async () => { - const { generativeAIResponse } = require("../services/ai.service"); - (generativeAIResponse as jest.Mock).mockResolvedValue("ok"); + const { streamAIResponse } = require("../services/ai.service"); + (streamAIResponse as jest.Mock).mockResolvedValue("ok"); const { prisma } = require("../db/prisma"); prisma.aIMessage.create.mockResolvedValue({ id: "1" }); @@ -125,7 +126,7 @@ describe("Rate limiting", () => { let aiLimitHit = false; for (let i = 0; i < 15; i++) { const res = await request(app) - .post("/api/ai/generate") + .post("/api/ai/stream") .set("Authorization", `Bearer ${TEST_TOKEN}`) .send({ prompt: "test", roomId: "room-1" }); diff --git a/backend/src/__tests__/controllers/ai.controller.test.ts b/backend/src/__tests__/controllers/ai.controller.test.ts index c96d1e0..269fa87 100644 --- a/backend/src/__tests__/controllers/ai.controller.test.ts +++ b/backend/src/__tests__/controllers/ai.controller.test.ts @@ -9,7 +9,7 @@ jest.mock("../../db/prisma", () => ({ })); jest.mock("../../services/ai.service", () => ({ - generativeAIResponse: jest.fn(), + streamAIResponse: jest.fn(), })); jest.mock("../../middleware/auth.middleware", () => ({ @@ -22,10 +22,11 @@ jest.mock("../../middleware/auth.middleware", () => ({ import request from "supertest"; import express from "express"; import aiRoutes from "../../routes/ai.route"; -import { generativeAIResponse } from "../../services/ai.service"; import { prisma } from "../../db/prisma"; +import { streamAIResponse } from "../../services/ai.service"; -const mockAIResponse = generativeAIResponse as jest.Mock; + +const mockStream = streamAIResponse as jest.Mock; const mockCreate = prisma.aIMessage.create as jest.Mock; const mockFindMany = prisma.aIMessage.findMany as jest.Mock; const mockDeleteMany = prisma.aIMessage.deleteMany as jest.Mock; @@ -37,73 +38,84 @@ app.use("/ai", aiRoutes); beforeEach(() => jest.clearAllMocks()); -// ── POST /ai/generate ───────────────────────────────────────────────────────── +// ── POST /ai/stream ───────────────────────────────────────────────────────── + +async function* mockStreamGenerator() { + yield "Hello "; + yield "World"; +} + +describe("POST /ai/stream", () => { -describe("POST /ai/generate", () => { + it("streams AI response and stores messages", async () => { + mockStream.mockReturnValueOnce(mockStreamGenerator()); - it("returns AI response and creates two DB messages (user + ai)", async () => { mockCreate.mockResolvedValue({ id: "msg-1" }); - mockAIResponse.mockResolvedValueOnce("Recursion is when a function calls itself."); const res = await request(app) - .post("/ai/generate") - .send({ prompt: "Explain recursion", roomId: "room-123" }); + .post("/ai/stream") + .send({ prompt: "hello", roomId: "room-123" }); expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.data).toBe("Recursion is when a function calls itself."); + + // SSE header check + expect(res.headers["content-type"]).toContain("text/event-stream"); + + // Response should contain streamed chunks + expect(res.text).toContain("Hello"); + expect(res.text).toContain("World"); + expect(res.text).toContain("done"); + + // DB calls expect(mockCreate).toHaveBeenCalledTimes(2); + + // user message expect(mockCreate).toHaveBeenNthCalledWith(1, { - data: { roomId: "room-123", role: "user", content: "Explain recursion" }, + data: { + userId: "test-user-id", + roomId: "room-123", + role: "user", + content: "hello", + }, }); + + // ai message (final combined response) expect(mockCreate).toHaveBeenNthCalledWith(2, { - data: { roomId: "room-123", role: "ai", content: "Recursion is when a function calls itself." }, + data: { + userId: "test-user-id", + roomId: "room-123", + role: "ai", + content: "Hello World", + }, }); }); it("returns 400 when prompt is missing", async () => { const res = await request(app) - .post("/ai/generate") + .post("/ai/stream") .send({ roomId: "room-123" }); expect(res.status).toBe(400); expect(res.body.error).toBe("Validation failed"); - expect(mockAIResponse).not.toHaveBeenCalled(); - expect(mockCreate).not.toHaveBeenCalled(); }); - it("returns 400 when prompt is falsy (empty string)", async () => { - const res = await request(app) - .post("/ai/generate") - .send({ prompt: "", roomId: "room-123" }); + it("handles AI stream error", async () => { + async function* errorStream() { + throw new Error("AI failed"); + } - expect(res.status).toBe(400); - expect(res.body.error).toBe("Validation failed"); - }); - - it("returns 500 when Gemini throws", async () => { - mockCreate.mockResolvedValueOnce({ id: "msg-1" }); - mockAIResponse.mockRejectedValueOnce(new Error("Gemini quota exceeded")); + mockStream.mockReturnValueOnce(errorStream()); + mockCreate.mockResolvedValue({ id: "msg-1" }); const res = await request(app) - .post("/ai/generate") + .post("/ai/stream") .send({ prompt: "test", roomId: "room-123" }); - expect(res.status).toBe(500); - expect(res.body.success).toBe(false); - expect(res.body.error).toBe("Failed to generate response"); - }); - - it("returns 500 when first prisma.create fails", async () => { - mockCreate.mockRejectedValueOnce(new Error("DB connection lost")); + expect(res.status).toBe(200); // still 200 (stream error handled inside) - const res = await request(app) - .post("/ai/generate") - .send({ prompt: "test", roomId: "room-123" }); - - expect(res.status).toBe(500); - expect(res.body.success).toBe(false); + expect(res.text).toContain("error"); }); + }); // ── GET /ai/history/:roomId ─────────────────────────────────────────────────── @@ -123,7 +135,7 @@ describe("GET /ai/history/:roomId", () => { expect(res.body).toHaveLength(2); expect(res.body[0].role).toBe("user"); expect(mockFindMany).toHaveBeenCalledWith({ - where: { roomId: "room-123" }, + where: { userId: "test-user-id" }, orderBy: { createdAt: "asc" }, }); }); @@ -155,7 +167,7 @@ describe("DELETE /ai/history/:roomId", () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(mockDeleteMany).toHaveBeenCalledWith({ - where: { roomId: "room-123" }, + where: { userId: "test-user-id" }, }); }); diff --git a/backend/src/__tests__/services/ai.service.test.ts b/backend/src/__tests__/services/ai.service.test.ts index c5ec74d..b60584c 100644 --- a/backend/src/__tests__/services/ai.service.test.ts +++ b/backend/src/__tests__/services/ai.service.test.ts @@ -1,54 +1,44 @@ // backend/src/__tests__/services/ai.service.test.ts -jest.mock("@google/generative-ai", () => { - const mockGenerateContent = jest.fn(); - return { - GoogleGenerativeAI: jest.fn().mockImplementation(() => ({ - getGenerativeModel: jest.fn().mockReturnValue({ - generateContent: mockGenerateContent, - }), - })), - // ✅ export the mock fn directly so tests can access it - __mockGenerateContent: mockGenerateContent, - }; -}); -import { generativeAIResponse } from "../../services/ai.service"; -// @ts-ignore -import { __mockGenerateContent as mockGenerateContent } from "@google/generative-ai"; +const mockGenerateContentStream = jest.fn(); + +jest.mock("@google/generative-ai", () => ({ + GoogleGenerativeAI: jest.fn().mockImplementation(() => ({ + getGenerativeModel: () => ({ + generateContentStream: mockGenerateContentStream, + }), + })), +})); + -describe("ai.service — generativeAIResponse", () => { +import { streamAIResponse } from "../../services/ai.service"; - beforeEach(() => jest.clearAllMocks()); - it("returns text from Gemini response", async () => { - mockGenerateContent.mockResolvedValueOnce({ - response: { text: () => "Here is the explanation." }, +describe("ai.service — streamAIResponse", () => { + + it("yields chunks from Gemini stream", async () => { + const mockStream = async function* () { + yield { text: () => "Hello " }; + yield { text: () => "World" }; + }; + + mockGenerateContentStream.mockResolvedValue({ + stream: mockStream(), }); - const result = await generativeAIResponse("Explain recursion"); - expect(result).toBe("Here is the explanation."); - expect(mockGenerateContent).toHaveBeenCalledWith("Explain recursion"); - }); + const chunks = []; - it("throws when prompt is empty string", async () => { - await expect(generativeAIResponse("")).rejects.toThrow("Prompt cannot be empty"); - }); + for await (const chunk of streamAIResponse("hello world")) { + chunks.push(chunk); + } - it("throws when prompt is whitespace only", async () => { - await expect(generativeAIResponse(" ")).rejects.toThrow("Prompt cannot be empty"); + expect(chunks).toEqual(["Hello ", "World"]); }); - - it("propagates Gemini API errors", async () => { - mockGenerateContent.mockRejectedValueOnce(new Error("API quota exceeded")); - await expect(generativeAIResponse("test")).rejects.toThrow("API quota exceeded"); + + it("throws when prompt is empty", async () => { + const gen = streamAIResponse(""); + await expect(gen.next()).rejects.toThrow("Prompt cannot be empty"); }); +}); - it("calls generateContent with the full prompt string", async () => { - mockGenerateContent.mockResolvedValueOnce({ - response: { text: () => "ok" }, - }); - await generativeAIResponse("What is a pointer in C?"); - expect(mockGenerateContent).toHaveBeenCalledWith("What is a pointer in C?"); - }); -}); \ No newline at end of file diff --git a/backend/src/__tests__/services/codeSnapshot.service.test.ts b/backend/src/__tests__/services/codeSnapshot.service.test.ts index e1f2601..9e5d782 100644 --- a/backend/src/__tests__/services/codeSnapshot.service.test.ts +++ b/backend/src/__tests__/services/codeSnapshot.service.test.ts @@ -20,6 +20,7 @@ import request from "supertest"; import express from "express"; import snapshotRoutes from "../../routes/codeSnapshot.routes"; import { prisma } from "../../db/prisma"; +import crypto from "crypto" import { saveCodeSnapshot } from "../../services/codeSnapshot.service"; const mockCreate = prisma.codeSnapshot.create as jest.Mock; @@ -45,10 +46,10 @@ describe("codeSnapshot.service — saveCodeSnapshot", () => { const created = { id: "snap-1", roomId: "room-123", code: "print('hi')", language: "python", createdAt: new Date() }; mockCreate.mockResolvedValueOnce(created); - const result = await saveCodeSnapshot("room-123", "print('hi')", "python"); + const result = await saveCodeSnapshot("room-123", "print('hi')", "python", "print('hi')"); expect(mockCreate).toHaveBeenCalledWith({ - data: { roomId: "room-123", code: "print('hi')", language: "python" }, + data: { roomId: "room-123", code: "print('hi')", language: "python", codeHash: expect.any(String), }, }); expect(result).toEqual(created); }); @@ -57,7 +58,7 @@ describe("codeSnapshot.service — saveCodeSnapshot", () => { mockFindFirst.mockResolvedValueOnce({ id: "snap-0", code: "old code", language: "python", createdAt: new Date() }); mockCreate.mockResolvedValueOnce({ id: "snap-1" }); - await saveCodeSnapshot("room-123", "new code", "python"); + await saveCodeSnapshot("room-123", "new code", "python", "print('hi')"); expect(mockCreate).toHaveBeenCalled(); }); @@ -65,14 +66,22 @@ describe("codeSnapshot.service — saveCodeSnapshot", () => { mockFindFirst.mockResolvedValueOnce({ id: "snap-0", code: "print('hi')", language: "python", createdAt: new Date() }); mockCreate.mockResolvedValueOnce({ id: "snap-1" }); - await saveCodeSnapshot("room-123", "print('hi')", "javascript"); + await saveCodeSnapshot("room-123", "print('hi')", "javascript", "console.log('hi')"); expect(mockCreate).toHaveBeenCalled(); }); it("skips creating when code AND language are identical to last snapshot", async () => { - mockFindFirst.mockResolvedValueOnce({ id: "snap-0", code: "print('hi')", language: "python", createdAt: new Date() }); - - const result = await saveCodeSnapshot("room-123", "print('hi')", "python"); + + mockFindFirst.mockResolvedValueOnce({ + code: "print('hi')", + language: "python", + codeHash: crypto + .createHash("sha256") + .update("print('hi')") + .digest("hex"), + }); + + const result = await saveCodeSnapshot("room-123", "print('hi')", "python", "print('hi')"); expect(mockCreate).not.toHaveBeenCalled(); expect(result).toBeUndefined(); @@ -82,7 +91,7 @@ describe("codeSnapshot.service — saveCodeSnapshot", () => { mockFindFirst.mockResolvedValueOnce(null); mockCreate.mockResolvedValueOnce({ id: "snap-1" }); - await saveCodeSnapshot("room-123", "code", "javascript"); + await saveCodeSnapshot("room-123", "code", "javascript", "print('hi')"); expect(mockFindFirst).toHaveBeenCalledWith({ where: { roomId: "room-123" }, @@ -92,7 +101,7 @@ describe("codeSnapshot.service — saveCodeSnapshot", () => { it("propagates DB errors from findFirst", async () => { mockFindFirst.mockRejectedValueOnce(new Error("DB error")); - await expect(saveCodeSnapshot("room-123", "code", "javascript")).rejects.toThrow("DB error"); + await expect(saveCodeSnapshot("room-123", "code", "javascript", "print('hi')")).rejects.toThrow("DB error"); }); }); @@ -110,7 +119,7 @@ describe("POST /snapshot/:roomId — saveSnapshotController", () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(mockCreate).toHaveBeenCalledWith({ - data: { roomId: "room-123", code: 'console.log("hi")', language: "javascript" }, + data: { roomId: "room-123", code: expect.any(String), language: "javascript", codeHash: expect.any(String), }, }); }); @@ -133,7 +142,7 @@ describe("POST /snapshot/:roomId — saveSnapshotController", () => { .send({ code: "#include", language: "c" }); expect(mockCreate).toHaveBeenCalledWith({ - data: { roomId: "room-abc", code: "#include", language: "c" }, + data: { roomId: "room-abc", code: expect.any(String), language: "c", codeHash: expect.any(String), }, }); }); }); diff --git a/backend/src/__tests__/services/room.service.test.ts b/backend/src/__tests__/services/room.service.test.ts index ff2a49f..52d539f 100644 --- a/backend/src/__tests__/services/room.service.test.ts +++ b/backend/src/__tests__/services/room.service.test.ts @@ -8,11 +8,33 @@ jest.mock("../../db/prisma", () => ({ }, })); +jest.mock("../../db/redis", () => ({ + redis: { + hGetAll: jest.fn(), + }, +})); + +jest.mock("../../lib/storage", () => ({ + uploadCodeToGCS: jest.fn().mockResolvedValue("https://fake-url.com/code"), + getCodeFromGCS: jest.fn(), +})); + +jest.mock("../../services/codeSnapshot.service", () => ({ + saveCodeSnapshot: jest.fn().mockResolvedValue(undefined), +})); + jest.mock("../../services/room.service", () => ({ getRoomById: jest.fn(), makeRoom: jest.fn(), })); +jest.mock("../../middleware/auth.middleware", () => ({ + authenticate: (req: any, res: any, next: any) => { + req.user = { userId: "test-user" }; // ✅ inject user + next(); + }, +})); + import request from "supertest"; import jwt from "jsonwebtoken" import express from "express"; @@ -21,12 +43,6 @@ import { makeRoom, getRoomById } from "../../services/room.service"; import { prisma } from "../../db/prisma"; import { redis } from "../../db/redis"; -jest.mock("../../db/redis", () => ({ - redis: { - hGetAll: jest.fn(), - }, -})); - const mockMakeRoom = makeRoom as jest.Mock; const mockUpdate = prisma.room.update as jest.Mock; const mockFindUnique = prisma.room.findUnique as jest.Mock; @@ -140,12 +156,12 @@ describe("GET /room/:roomId — getRoomById", () => { describe("POST /:roomId/save — saveRoomCode", () => { + it("updates room and returns { success: true }", async () => { mockFindUnique.mockResolvedValueOnce({ id: "room-123", code: "console.log('hi')", language: "javascript", - createdAt: new Date(), userId: "test-user", }); @@ -153,34 +169,24 @@ describe("POST /:roomId/save — saveRoomCode", () => { id: "room-123", code: "print('hi')", language: "python", - createdAt: new Date(), }); - // Redis mock (Jest) (redis.hGetAll as jest.Mock).mockResolvedValueOnce({ "socket-1": JSON.stringify({ userId: "test-user", - socketId: "socket-1", - username: "rahul", - color: "#fff", }), }); const res = await request(app) .post("/room-123/save") .set("Authorization", `Bearer ${token}`) + .set("Origin", "http://localhost:5173") .send({ code: "print('hi')", language: "python" }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); - - expect(mockUpdate).toHaveBeenCalledWith({ - where: { id: "room-123" }, - data: { code: "print('hi')", language: "python" }, - }); }); - it("returns 500 with { error } when DB update fails", async () => { mockFindUnique.mockResolvedValueOnce({ id: "room-123", code: "console.log('hi')", language: "javascript", createdAt: new Date(), userId: "test-user", diff --git a/backend/src/app.ts b/backend/src/app.ts index 5904c92..2f51c65 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -33,9 +33,6 @@ app.use( }) ); -//--------BODY--PARSER-------------------------------------- -app.use(express.json({ limit: "50kb"})); -app.use(express.text()); //----------GLOBAL--RATE--LIMITING--------------------------- const globalRateLimiter = rateLimit({ @@ -47,6 +44,9 @@ const globalRateLimiter = rateLimit({ }); app.use(globalRateLimiter); +//--------BODY--PARSER-------------------------------------- +app.use(express.json({ limit: "50kb"})); + //------AI--specific--tighter--rate--limit------------------- export const aiRateLimiter = rateLimit({ windowMs: 60_000, diff --git a/backend/src/controllers/ai.controller.ts b/backend/src/controllers/ai.controller.ts index 9697d78..bbad41a 100644 --- a/backend/src/controllers/ai.controller.ts +++ b/backend/src/controllers/ai.controller.ts @@ -1,51 +1,13 @@ import { Request, Response } from "express"; import { AIRequest } from "../types"; -import { generativeAIResponse, streamAIResponse } from "../services/ai.service"; +import { streamAIResponse } from "../services/ai.service"; import { prisma } from "../db/prisma"; import { logger } from "../utils/logger"; - -export const generateResponse = async (req: Request<{}, {}, AIRequest>, res: Response) => { - const { prompt, roomId } = req.body; - - try { - if (!prompt) return res.status(400).json({ error: "Prompt is required" }); - - // save user message - await prisma.aIMessage.create({ - data: { - roomId, - role: "user", - content: prompt, - }, - }); - - const response = await generativeAIResponse(prompt); - - // save ai message - await prisma.aIMessage.create({ - data: { - roomId, - role: "ai", - content: response, - }, - }); - - res.json({ - success: true, - data: response, - }); - } catch { - - res.status(500).json({ - success: false, - error: "Failed to generate response" , - }); - } -}; - export const streamAiResponse = async (req: Request<{}, {}, AIRequest>, res: Response) => { + const { prompt, roomId } = req.body; + const userId = (req as any).user?.userId; if (!prompt) return res.status(400).json({ error: "Prompt required" }); // SSE headers @@ -62,7 +24,7 @@ export const streamAiResponse = async (req: Request<{}, {}, AIRequest>, res: Res req.on("close", () => { clearInterval(interval)}); - await prisma.aIMessage.create({ data: { roomId, role: "user", content: prompt } }); + await prisma.aIMessage.create({ data: { userId, roomId, role: "user", content: prompt } }); let fullResponse = ""; try { @@ -72,29 +34,29 @@ export const streamAiResponse = async (req: Request<{}, {}, AIRequest>, res: Res } res.write(`data: ${JSON.stringify({ done: true })}\n\n`); + clearInterval(interval); res.end(); - await prisma.aIMessage.create({ data: { roomId, role: "ai", content: fullResponse } }); - } catch { + await prisma.aIMessage.create({ data: { userId, roomId, role: "ai", content: fullResponse } }); + } catch (error) { + clearInterval(interval); + logger.error("AI Error : { error: error instanceof Error ? error.message : String(error) } "); res.write(`data: ${JSON.stringify({ error: "AI generation failed" })}\n\n`); res.end(); } }; - export const getAIHistory = async (req: Request, res: Response) => { - const { roomId } = req.params; - + const userId = (req as any).user?.userId; try { const messages = await prisma.aIMessage.findMany({ - where: { roomId: roomId as string }, + where: { userId: userId as string }, orderBy: { createdAt: "asc" }, }); res.json(messages); } catch { - res.status(500).json({ success: false, error: "Failed to fetch AI history", @@ -103,11 +65,11 @@ export const getAIHistory = async (req: Request, res: Response) => { }; export const clearAIHistory = async (req: Request, res: Response) => { - const { roomId } = req.params; - + + const userId = (req as any).user?.userId; try { await prisma.aIMessage.deleteMany({ - where: { roomId: roomId as string }, + where: { userId: userId as string }, }); res.json({ success: true }); diff --git a/backend/src/controllers/codeSnapshot.controller.ts b/backend/src/controllers/codeSnapshot.controller.ts index f4f8cd0..3ec714a 100644 --- a/backend/src/controllers/codeSnapshot.controller.ts +++ b/backend/src/controllers/codeSnapshot.controller.ts @@ -1,17 +1,21 @@ import { Request, Response } from "express"; import { prisma } from "../db/prisma"; import { saveCodeSnapshot } from "../services/codeSnapshot.service"; +import { getCodeFromGCS, uploadCodeToGCS } from "../lib/storage"; +import { logger } from "../utils/logger"; export const saveSnapshotController = async (req: Request, res: Response) => { try { const { roomId } = req.params; const { code, language } = req.body; + const codeUrl = await uploadCodeToGCS(code, roomId as string); await saveCodeSnapshot( roomId as string, - code as string, - language as string + codeUrl as string, + language as string, + code as string, ) res.json({ success: true }); @@ -29,7 +33,19 @@ export const getSnapshotsController = async (req: Request, res: Response) => { orderBy: { createdAt: "desc" }, }); - res.json(snapshots); + const result = await Promise.all( + + snapshots.map(async (snap) => { + try { + const code = await getCodeFromGCS(snap.code); + return { ...snap, code} + } catch (err) { + logger.error("Failed to fetch snapshot from GCS : "); + return { ...snap, code: "// Failed to load code" }; + } + }) + ); + res.json(result); } catch { res.status(500).json({ error: "Failed to fetch snapshots"}); } diff --git a/backend/src/controllers/room.controller.ts b/backend/src/controllers/room.controller.ts index 4762a1e..1349fba 100644 --- a/backend/src/controllers/room.controller.ts +++ b/backend/src/controllers/room.controller.ts @@ -1,8 +1,11 @@ import { Response } from "express"; import { prisma } from "../db/prisma"; import { makeRoom, createRoom as createRoomService } from "../services/room.service"; +import { saveCodeSnapshot } from "../services/codeSnapshot.service"; import { AuthRequest } from "../middleware/auth.middleware"; import { redis } from "../db/redis"; +import { getCodeFromGCS, uploadCodeToGCS } from "../lib/storage"; +import { logger } from "../utils/logger"; // GET ROOM DATA export const getRoomData = async (req: AuthRequest, res: Response) => { @@ -12,10 +15,22 @@ export const getRoomData = async (req: AuthRequest, res: Response) => { const userId = req.user?.userId; const room = await makeRoom(roomId as string, userId); + let code = room.code; + + if (room.codeUrl) { + try { + code = await getCodeFromGCS(room.codeUrl); + } catch { + logger.warn("GCS fetch failed, fallback to DB"); + } + } else { + code = room.code; + } + res.json({ roomId: room.id, language: room.language, - code: room.code, + code }); } catch { res.status(500).json({ error: "Failed to load room" }); @@ -72,15 +87,19 @@ export const saveRoomCode = async (req: AuthRequest, res: Response) => { if (!isOwner && !isParticipant) { return res.status(403).json({ error: "Unauthorized" }); } - + + const codeUrl = await uploadCodeToGCS(code, roomId); await prisma.room.update({ where: { id: roomId }, data: { code, + codeUrl, language, }, }); + await saveCodeSnapshot(roomId, codeUrl, language, code); + res.json({ success: true }); } catch { res.status(500).json({ error: "Failed to save room" }); diff --git a/backend/src/lib/storage.ts b/backend/src/lib/storage.ts new file mode 100644 index 0000000..28beede --- /dev/null +++ b/backend/src/lib/storage.ts @@ -0,0 +1,46 @@ +import { Storage } from "@google-cloud/storage" + +const isTest = process.env.NODE_ENV === "test"; + +const storage = isTest + ? null + : new Storage({ + keyFilename: "./gcp-key.json", + }); + +const bucket = isTest ? null : storage!.bucket("smart-code-lab"); + +export const uploadCodeToGCS = async (code: string, roomId: string) => { + if (process.env.NODE_ENV === "test") { + return `rooms/${roomId}/mock.txt`; // ✅ fake path + } + + const file = bucket!.file(`rooms/${roomId}/${Date.now()}.txt`); + + await file.save(code, { + contentType: "text/plain", + }); + + return file.name; +}; + +export const getCodeFromGCS = async (filePath: string) => { + if (process.env.NODE_ENV === "test") { + return "mock code"; // ✅ fake content + } + + const file = bucket!.file(filePath); + + const [signedUrl] = await file.getSignedUrl({ + action: "read", + expires: Date.now() + 15 * 60 * 1000, + }); + + const res = await fetch(signedUrl); + + if (!res.ok) { + throw new Error("Failed to fetch code from GCS"); + } + + return await res.text(); +}; \ No newline at end of file diff --git a/backend/src/routes/ai.route.ts b/backend/src/routes/ai.route.ts index 5150dc7..f4725fe 100644 --- a/backend/src/routes/ai.route.ts +++ b/backend/src/routes/ai.route.ts @@ -1,11 +1,10 @@ import { Router } from "express"; -import { clearAIHistory, generateResponse, getAIHistory, streamAiResponse } from "../controllers/ai.controller"; +import { clearAIHistory, getAIHistory, streamAiResponse } from "../controllers/ai.controller"; import { validate, aiGenerateSchema } from "../middleware/validate"; import { authenticate } from "../middleware/auth.middleware" const router = Router(); -router.post("/generate", authenticate, validate(aiGenerateSchema), generateResponse); router.post("/stream", authenticate, validate(aiGenerateSchema), streamAiResponse); router.get("/history/:roomId", authenticate, getAIHistory); router.delete("/history/:roomId", authenticate, clearAIHistory); diff --git a/backend/src/services/ai.service.ts b/backend/src/services/ai.service.ts index df36cc1..66f5e0b 100644 --- a/backend/src/services/ai.service.ts +++ b/backend/src/services/ai.service.ts @@ -5,22 +5,12 @@ import { logger } from "../utils/logger"; const genAi = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!); const model = genAi.getGenerativeModel({ model: "gemini-2.5-flash" }); -// ----------------GENERATIVE AI RESPONSE-------------------------- -export const generativeAIResponse = async (prompt: string): Promise => { - if (!prompt?.trim()) throw new Error("Prompt cannot be empty"); - - logger.info(`Generating response (prompt length: ${prompt.length})`); - - const result = await model.generateContent(prompt); - const text = result.response.text(); - - logger.info(`[AI] Response generated (response length: ${text.length})`); - return text; -}; - export async function* streamAIResponse(prompt: string): AsyncGenerator { + if (!prompt?.trim()) throw new Error("Prompt cannot be empty"); + logger.info(`[AI] Streaming (prompt length: ${prompt.length})`); + const result = await model.generateContentStream(prompt); for await (const chunk of result.stream) { @@ -28,5 +18,4 @@ export async function* streamAIResponse(prompt: string): AsyncGenerator if (text) yield text; } - } \ No newline at end of file diff --git a/backend/src/services/codeSnapshot.service.ts b/backend/src/services/codeSnapshot.service.ts index 7dfc5a3..5cd126c 100644 --- a/backend/src/services/codeSnapshot.service.ts +++ b/backend/src/services/codeSnapshot.service.ts @@ -1,26 +1,33 @@ import { prisma } from "../db/prisma"; +import crypto from "crypto" export const saveCodeSnapshot = async ( roomId: string, - code: string, - language: string + codeUrl: string, + language: string, + rawCode: string, ) => { + const codeHash = crypto + .createHash("sha256") + .update(rawCode) + .digest("hex"); + const lastSnapshot = await prisma.codeSnapshot.findFirst({ where: { roomId }, orderBy: { createdAt: "desc" }, }); - // If same code, skip saving - if (lastSnapshot?.code === code && lastSnapshot?.language === language) { - return; - } + // SAFE DEDUP CHECK + if (lastSnapshot?.codeHash && lastSnapshot.codeHash === codeHash && lastSnapshot.language === language) return; + return await prisma.codeSnapshot.create({ data: { roomId, - code, + code: codeUrl, language, + codeHash }, }); }; \ No newline at end of file diff --git a/backend/src/sockets/socket.ts b/backend/src/sockets/socket.ts index 59dc092..6176094 100644 --- a/backend/src/sockets/socket.ts +++ b/backend/src/sockets/socket.ts @@ -58,7 +58,7 @@ export const initSocket = (server: http.Server) => { io.on("connection", (socket) => { logger.info("[SOCKET] Client connected", { socketId: socket.id}); - + // JOIN ROOM socket.on("join", async ({ RoomId }: { RoomId: string }) => { @@ -86,7 +86,7 @@ export const initSocket = (server: http.Server) => { } socket.join(RoomId); - await redis.set(`socket:${socket.id}:room`, RoomId); + await redis.set(`socket:${socket.id}:room`, RoomId, { EX : 60 * 60 * 24 }); const usersKey = `room:${RoomId}:users`; @@ -104,6 +104,8 @@ export const initSocket = (server: http.Server) => { JSON.stringify({ username, socketId: socket.id, userId, color }) ); + await redis.expire(usersKey, 60 * 60 * 24); + // fetch updated users const updatedUsers = await redis.hGetAll(usersKey); @@ -147,10 +149,8 @@ export const initSocket = (server: http.Server) => { const roomId = await redis.get(`socket:${socket.id}:room`); if (!roomId) return; - await redis.set( - `room:${roomId}:content`, - JSON.stringify({ code, language}) - ); + await redis.set(`room:${roomId}:content`, JSON.stringify({ code, language }), { EX: 24 * 60 * 60 } ); + // Broadcast code changes to others in the room socket.to(roomId).emit("content-edited", { code, language }); diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 2465f7c..3f73bf0 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -40,10 +40,5 @@ export type CompileResponse = { export type AIRequest = { prompt: string; roomId: string; + code: string; }; - -export type AIResponse = { - success: boolean; - data?: string; - error?: string; -}; \ No newline at end of file diff --git a/frontend/src/components/AIPanel.tsx b/frontend/src/components/AIPanel.tsx index 995d1f5..9bad082 100644 --- a/frontend/src/components/AIPanel.tsx +++ b/frontend/src/components/AIPanel.tsx @@ -11,6 +11,7 @@ import api from "../lib/authAxios"; import geminiLogo from "../assets/geminiLogo.png"; import type { Components } from "react-markdown"; +import { getUserFromToken } from "../utils/auth"; interface AIMessage { role: "user" | "ai"; @@ -54,6 +55,11 @@ export default function AIPanel({ const [aiPanelHeight, setAiPanelHeight] = useState(AI_DEFAULT_HEIGHT); const aiPanelHeightRef = useRef(AI_DEFAULT_HEIGHT); // always current, no stale closure const dragStartY = useRef(0); + + + const user = getUserFromToken(); + const name = user?.username?.charAt(0).toUpperCase() + user?.username?.slice(1); + // Keep ref in sync with state so onDragStart never reads stale height const handleHeightChange = (h: number) => { @@ -292,7 +298,7 @@ export default function AIPanel({ history.map((msg, i) => (
- {msg.role === "user" ? "You" : "Gemini"} + {msg.role === "user" ? name : "Gemini"}
{ diff --git a/frontend/src/hooks/useAI.ts b/frontend/src/hooks/useAI.ts index 8e91d62..326319a 100644 --- a/frontend/src/hooks/useAI.ts +++ b/frontend/src/hooks/useAI.ts @@ -1,6 +1,7 @@ import { useState, useRef, useEffect } from "react"; import toast from "react-hot-toast"; import api from "../lib/authAxios"; +import type { editor } from "monaco-editor"; const RATE_LIMIT_MAX = 5; const RATE_LIMIT_WINDOW = 60_000; @@ -9,6 +10,7 @@ interface UseAIProps { userCode: string; userLang: string; roomId: string; + editorRef: React.MutableRefObject; } interface AIMessage { @@ -16,7 +18,7 @@ interface AIMessage { content: string; } -export function useAI({ userCode, userLang, roomId }: UseAIProps) { +export function useAI({ userCode, userLang, roomId, editorRef }: UseAIProps) { const [isAiThinking, setIsAiThinking] = useState(false); const [rateCooldown, setRateCooldown] = useState(0); const [aiQuestion, setAiQuestion] = useState(""); @@ -44,13 +46,15 @@ export function useAI({ userCode, userLang, roomId }: UseAIProps) { useEffect(() => { const fetchHistory = async () => { try { + setHistory([]); const res = await api.get(`/ai/history/${roomId}`); setHistory(res.data); } catch (e) { toast.error("Fetching Error"); } }; - if (roomId) fetchHistory(); + + fetchHistory(); }, [roomId]); @@ -125,13 +129,15 @@ export function useAI({ userCode, userLang, roomId }: UseAIProps) { const token = localStorage.getItem("token"); try { + const currentCode = editorRef.current?.getValue() || userCode; + const res = await fetch(`${URL}/api/ai/stream`, { method: "POST", headers: { "Content-Type":"application/json", "Authorization":`Bearer ${token}`, }, - body: JSON.stringify({ prompt, roomId }), + body: JSON.stringify({ prompt, code: currentCode, language: userLang, roomId }), }); if (!res.ok) throw new Error("stream failed"); diff --git a/frontend/src/hooks/useEditorPersistence.ts b/frontend/src/hooks/useEditorPersistence.ts index e60c262..3f55dc7 100644 --- a/frontend/src/hooks/useEditorPersistence.ts +++ b/frontend/src/hooks/useEditorPersistence.ts @@ -135,7 +135,7 @@ export function useEditorPersistence({ isLanguageSwitching.current = false; socket.emit("content-edited", { code, language }); - api.post(`/room/${roomId}/save`, { code, language }); + //api.post(`/room/${roomId}/save`, { code, language }); setRefreshHistory(prev => prev + 1); toast.success("Snapshot restored"); }; diff --git a/frontend/src/test/components/AIPanel.test.tsx b/frontend/src/test/components/AIPanel.test.tsx index 67890e5..e1589d5 100644 --- a/frontend/src/test/components/AIPanel.test.tsx +++ b/frontend/src/test/components/AIPanel.test.tsx @@ -138,17 +138,17 @@ describe("AIPanel — execution output", () => { describe("AIPanel — chat history", () => { beforeEach(() => vi.clearAllMocks()); + beforeEach(() => localStorage.setItem("username", "Rahul")); it("shows empty state when history is empty and not thinking", () => { renderPanel({ history: [], isAiThinking: false }); expect(screen.getByText("Click Ask Gemini or type a question above")).toBeInTheDocument(); }); - it("renders user messages with 'You' label", () => { + it("renders user messages with 'username' label", () => { renderPanel({ - history: [{ role: "user", content: "What is a pointer?" }], + history: [{ role: "user", content: "What is a pointer?" , createdAt: new Date().toISOString() }], }); - expect(screen.getByText("You")).toBeInTheDocument(); expect(screen.getByText("What is a pointer?")).toBeInTheDocument(); }); diff --git a/frontend/src/test/hooks/useAI.test.ts b/frontend/src/test/hooks/useAI.test.ts index a6a573f..f9fc427 100644 --- a/frontend/src/test/hooks/useAI.test.ts +++ b/frontend/src/test/hooks/useAI.test.ts @@ -12,6 +12,18 @@ vi.mock("../lib/authAxios", () => ({ }, })); +global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: Hello\n\n")); + controller.close(); + } + }) + }) +) as any; + import api from "../../lib/authAxios"; import toast from "react-hot-toast"; import { useAI } from "../../hooks/useAI"; @@ -43,9 +55,14 @@ const defaultProps = { userCode: 'console.log("hello")', userLang: "javascript", roomId: "room-test-123", + editorRef: { + current: { + getValue: () => 'console.log("Hello")' + } + } as any, }; -function renderAI(props = {}) { +function renderAI(props: Partial = {}) { return renderHook(() => useAI({ ...defaultProps, ...props })); } diff --git a/frontend/src/test/hooks/useEditorPersistence.test.ts b/frontend/src/test/hooks/useEditorPersistence.test.ts index 61000e8..0d9a295 100644 --- a/frontend/src/test/hooks/useEditorPersistence.test.ts +++ b/frontend/src/test/hooks/useEditorPersistence.test.ts @@ -254,7 +254,6 @@ describe("useEditorPersistence", () => { expect(mockSetUserLang).toHaveBeenCalledWith("python"); expect(mockEditorRef.current.setValue).toHaveBeenCalledWith("snapshot code"); expect(socket.emit).toHaveBeenCalledWith("content-edited", { code: "snapshot code", language: "python" }); - expect(axios.post).toHaveBeenCalledWith(expect.stringContaining("/save"), expect.any(Object)); expect(toast.success).toHaveBeenCalledWith("Snapshot restored"); });