From 8167cacca8657fd81fb0c8db6e8517cadc5febbe Mon Sep 17 00:00:00 2001 From: "YIHSUEN\\Yi Hsuen" Date: Wed, 25 Oct 2023 00:35:42 +0800 Subject: [PATCH 01/50] Downsize prod CPU requests --- deployment/gke-prod-manifests/admin-service-deployment.yaml | 2 +- .../gke-prod-manifests/collaboration-service-deployment.yaml | 2 +- deployment/gke-prod-manifests/frontend-deployment.yaml | 2 +- deployment/gke-prod-manifests/gateway-deployment.yaml | 2 +- deployment/gke-prod-manifests/matching-service-deployment.yaml | 2 +- deployment/gke-prod-manifests/question-service-deployment.yaml | 2 +- deployment/gke-prod-manifests/user-service-deployment.yaml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/deployment/gke-prod-manifests/admin-service-deployment.yaml b/deployment/gke-prod-manifests/admin-service-deployment.yaml index 94f8357c..7a28ae35 100644 --- a/deployment/gke-prod-manifests/admin-service-deployment.yaml +++ b/deployment/gke-prod-manifests/admin-service-deployment.yaml @@ -36,6 +36,6 @@ spec: # You must specify requests for CPU to autoscale # based on CPU utilization requests: - cpu: "250m" + cpu: "100m" restartPolicy: Always status: {} diff --git a/deployment/gke-prod-manifests/collaboration-service-deployment.yaml b/deployment/gke-prod-manifests/collaboration-service-deployment.yaml index 90c9d724..692ee763 100644 --- a/deployment/gke-prod-manifests/collaboration-service-deployment.yaml +++ b/deployment/gke-prod-manifests/collaboration-service-deployment.yaml @@ -51,6 +51,6 @@ spec: # You must specify requests for CPU to autoscale # based on CPU utilization requests: - cpu: "250m" + cpu: "100m" restartPolicy: Always status: {} diff --git a/deployment/gke-prod-manifests/frontend-deployment.yaml b/deployment/gke-prod-manifests/frontend-deployment.yaml index d1ae9eef..4e299d0c 100644 --- a/deployment/gke-prod-manifests/frontend-deployment.yaml +++ b/deployment/gke-prod-manifests/frontend-deployment.yaml @@ -28,6 +28,6 @@ spec: # You must specify requests for CPU to autoscale # based on CPU utilization requests: - cpu: "250m" + cpu: "100m" restartPolicy: Always status: {} diff --git a/deployment/gke-prod-manifests/gateway-deployment.yaml b/deployment/gke-prod-manifests/gateway-deployment.yaml index cdf967c7..6c429131 100644 --- a/deployment/gke-prod-manifests/gateway-deployment.yaml +++ b/deployment/gke-prod-manifests/gateway-deployment.yaml @@ -38,6 +38,6 @@ spec: # You must specify requests for CPU to autoscale # based on CPU utilization requests: - cpu: "250m" + cpu: "100m" restartPolicy: Always status: {} diff --git a/deployment/gke-prod-manifests/matching-service-deployment.yaml b/deployment/gke-prod-manifests/matching-service-deployment.yaml index fea7a690..44471cdf 100644 --- a/deployment/gke-prod-manifests/matching-service-deployment.yaml +++ b/deployment/gke-prod-manifests/matching-service-deployment.yaml @@ -36,6 +36,6 @@ spec: # You must specify requests for CPU to autoscale # based on CPU utilization requests: - cpu: "250m" + cpu: "100m" restartPolicy: Always status: {} diff --git a/deployment/gke-prod-manifests/question-service-deployment.yaml b/deployment/gke-prod-manifests/question-service-deployment.yaml index d54a5a59..c3492713 100644 --- a/deployment/gke-prod-manifests/question-service-deployment.yaml +++ b/deployment/gke-prod-manifests/question-service-deployment.yaml @@ -36,6 +36,6 @@ spec: # You must specify requests for CPU to autoscale # based on CPU utilization requests: - cpu: "250m" + cpu: "100m" restartPolicy: Always status: {} diff --git a/deployment/gke-prod-manifests/user-service-deployment.yaml b/deployment/gke-prod-manifests/user-service-deployment.yaml index ee8293f4..cca09053 100644 --- a/deployment/gke-prod-manifests/user-service-deployment.yaml +++ b/deployment/gke-prod-manifests/user-service-deployment.yaml @@ -36,6 +36,6 @@ spec: # You must specify requests for CPU to autoscale # based on CPU utilization requests: - cpu: "250m" + cpu: "100m" restartPolicy: Always status: {} From c1ff1fa8856af7cb7a9753388f29444bef2a30bc Mon Sep 17 00:00:00 2001 From: "YIHSUEN\\Yi Hsuen" Date: Wed, 25 Oct 2023 08:33:49 +0800 Subject: [PATCH 02/50] Re-arrange frontend dependencies --- frontend/package.json | 6 ++---- start-app-no-docker.sh | 2 +- yarn.lock | 26 ++++++-------------------- 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 0e247edc..dcb12397 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,9 +30,6 @@ "@radix-ui/react-tabs": "^1.0.4", "@tanstack/react-query": "^5.0.0", "@tanstack/react-table": "^8.10.4", - "@types/node": "20.6.0", - "@types/react": "18.2.21", - "@types/react-dom": "18.2.7", "@uiball/loaders": "^1.3.0", "autoprefixer": "10.4.15", "class-variance-authority": "^0.7.0", @@ -62,7 +59,8 @@ "devDependencies": { "@types/diff-match-patch": "^1.0.34", "@types/lodash": "^4.14.199", - "@types/react": "^18.2.30", + "@types/node": "^20.8.8", + "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", "@types/socket.io-client": "^3.0.0", "eslint": "^8.51.0", diff --git a/start-app-no-docker.sh b/start-app-no-docker.sh index 87a27f64..99273b34 100755 --- a/start-app-no-docker.sh +++ b/start-app-no-docker.sh @@ -6,7 +6,7 @@ prepend() { done } -(yarn && yarn prisma generate && \ +(yarn install --frozen-lockfile && yarn prisma generate && \ trap 'kill 0' INT TERM; \ (yarn workspace frontend dev:local | prepend "frontend: ") & \ (yarn workspace user-service dev:local | prepend "user-service: ") & \ diff --git a/yarn.lock b/yarn.lock index f5a017e6..19c965c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3193,18 +3193,13 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@^20.8.7": +"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@^20.8.7", "@types/node@^20.8.8": version "20.8.8" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.8.tgz#adee050b422061ad5255fc38ff71b2bb96ea2a0e" integrity sha512-YRsdVxq6OaLfmR9Hy816IMp33xOBjfyOgUd77ehqg96CFywxAPbDbXvAsuN2KVg2HOT8Eh6uAfU+l4WffwPVrQ== dependencies: undici-types "~5.25.1" -"@types/node@20.6.0": - version "20.6.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.0.tgz#9d7daa855d33d4efec8aea88cd66db1c2f0ebe16" - integrity sha512-najjVq5KN2vsH2U/xyh2opaSEz6cZMR2SetLIlxlj08nOcmPOemJmUK2o4kUzfLqfrWE0PIrNeE16XhYDd3nqg== - "@types/parse-json@^4.0.0": version "4.0.1" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.1.tgz#27f7559836ad796cea31acb63163b203756a5b4e" @@ -3225,10 +3220,10 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.6.tgz#7cb33992049fd7340d5b10c0098e104184dfcd2a" integrity sha512-+0autS93xyXizIYiyL02FCY8N+KkKPhILhcUSA276HxzreZ16kl+cmwvV2qAM/PuCCwPXzOXOWhiPcw20uSFcA== -"@types/react-dom@18.2.7": - version "18.2.7" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.7.tgz#67222a08c0a6ae0a0da33c3532348277c70abb63" - integrity sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA== +"@types/react-dom@^18.2.14": + version "18.2.14" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.14.tgz#c01ba40e5bb57fc1dc41569bb3ccdb19eab1c539" + integrity sha512-V835xgdSVmyQmI1KLV2BEIUgqEuinxp9O4G6g3FqO/SqLac049E53aysv0oEFD2kHfejeKU+ZqL2bcFWj9gLAQ== dependencies: "@types/react" "*" @@ -3239,7 +3234,7 @@ dependencies: "@types/react" "*" -"@types/react@*": +"@types/react@*", "@types/react@^18.2.31": version "18.2.31" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.31.tgz#74ae2630e4aa9af599584157abd3b95d96fb9b40" integrity sha512-c2UnPv548q+5DFh03y8lEDeMfDwBn9G3dRwfkrxQMo/dOtRHUUO57k6pHvBIfH/VF4Nh+98mZ5aaSe+2echD5g== @@ -3248,15 +3243,6 @@ "@types/scheduler" "*" csstype "^3.0.2" -"@types/react@18.2.21": - version "18.2.21" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.21.tgz#774c37fd01b522d0b91aed04811b58e4e0514ed9" - integrity sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA== - dependencies: - "@types/prop-types" "*" - "@types/scheduler" "*" - csstype "^3.0.2" - "@types/rimraf@^3.0.2": version "3.0.2" resolved "https://registry.yarnpkg.com/@types/rimraf/-/rimraf-3.0.2.tgz#a63d175b331748e5220ad48c901d7bbf1f44eef8" From ae0c485a79046ef67ff438db4c2e857160714aa2 Mon Sep 17 00:00:00 2001 From: Lee Chun Wei <47494777+chunweii@users.noreply.github.com> Date: Thu, 26 Oct 2023 21:00:52 +0800 Subject: [PATCH 03/50] Clear timeout in useEffect (#174) Fixes #173 --- frontend/src/pages/interviews/find-match.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/interviews/find-match.tsx b/frontend/src/pages/interviews/find-match.tsx index 45e287f1..dbb915c2 100644 --- a/frontend/src/pages/interviews/find-match.tsx +++ b/frontend/src/pages/interviews/find-match.tsx @@ -15,14 +15,19 @@ export default function FindMatch() { }; useEffect(() => { + let timeout: ReturnType | null = null; if (match) { router.push("/interviews/match-found"); } else { - setTimeout(() => { + timeout = setTimeout(() => { cancelLooking(); router.push("/interviews/match-not-found"); }, 30000); } + return () => { + if (timeout) + clearTimeout(timeout); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [match, router]); From 0f815765a47c07b392aed9ff7d65d72564677421 Mon Sep 17 00:00:00 2001 From: Tay Yi Hsuen Date: Thu, 26 Oct 2023 21:01:35 +0800 Subject: [PATCH 04/50] Fix gateway user verification (#186) Gateway does not detect the user id param in the path, causing verification to fail and redirect to the frontend address. Let's add a new header to store the current user id, which should be the same as that of the param uid. --- .../firebase-client/useDeleteOwnAccount.ts | 1 + frontend/src/pages/api/userHandler.ts | 3 +- services/gateway/src/auth/auth.ts | 4 +- services/user-service/openapiDoc.json | 6 ++ services/user-service/src/routes/index.ts | 86 +++++++++++++------ services/user-service/systemtest/app.test.ts | 39 ++++++++- .../user-service/test/routes/index.test.ts | 57 ++++++++++-- 7 files changed, 158 insertions(+), 38 deletions(-) diff --git a/frontend/src/firebase-client/useDeleteOwnAccount.ts b/frontend/src/firebase-client/useDeleteOwnAccount.ts index 63759825..11fa14a2 100644 --- a/frontend/src/firebase-client/useDeleteOwnAccount.ts +++ b/frontend/src/firebase-client/useDeleteOwnAccount.ts @@ -15,6 +15,7 @@ export const useDeleteOwnAccount = () => { method: "DELETE", headers: { "User-Id-Token": idToken, + "User-Id": currentUser.uid }, }); // This will delete the user from the Firebase Authentication database diff --git a/frontend/src/pages/api/userHandler.ts b/frontend/src/pages/api/userHandler.ts index aa6df926..67c7158b 100644 --- a/frontend/src/pages/api/userHandler.ts +++ b/frontend/src/pages/api/userHandler.ts @@ -3,7 +3,7 @@ import { EditableUser } from "@/types/UserTypes"; export const updateUserByUid = async (user: EditableUser, currentUser: any) => { try { - const url = `${userApiPathAddress}${currentUser.uid}}`; + const url = `${userApiPathAddress}${currentUser.uid}`; const idToken = await currentUser.getIdToken(true); console.log("user", user); @@ -14,6 +14,7 @@ export const updateUserByUid = async (user: EditableUser, currentUser: any) => { headers: { "Content-Type": "application/json", "User-Id-Token": idToken, + "User-Id": currentUser.uid }, body: JSON.stringify(user), }); diff --git a/services/gateway/src/auth/auth.ts b/services/gateway/src/auth/auth.ts index 2aac2d48..0d7a93b5 100644 --- a/services/gateway/src/auth/auth.ts +++ b/services/gateway/src/auth/auth.ts @@ -4,6 +4,7 @@ import {frontendAddress} from "../proxied_routes/service_names"; const redirectLink = frontendAddress; const userIdTokenHeader = "User-Id-Token"; +const userIdHeader = "User-Id"; export const setupIsLoggedIn = (app : Express, routes : any[]) => { routes.forEach(r => { @@ -32,8 +33,9 @@ export const setupUserIdMatch = (app : Express, routes : any[]) => { routes.forEach(r => { app.use(r.url, function(req : express.Request, res : express.Response, next : express.NextFunction) { if (r.user_match_required_methods.includes(req.method)) { + console.log(req.params) const idToken = req.get(userIdTokenHeader); - const paramUid = req.params.uid; + const paramUid = req.get(userIdHeader); if (!idToken || !paramUid) { res.redirect(redirectLink) } else { diff --git a/services/user-service/openapiDoc.json b/services/user-service/openapiDoc.json index 6fdf8c16..3dce4a77 100644 --- a/services/user-service/openapiDoc.json +++ b/services/user-service/openapiDoc.json @@ -68,6 +68,9 @@ "200": { "description": "OK" }, + "400": { + "description": "Bad Request" + }, "404": { "description": "Not Found" }, @@ -92,6 +95,9 @@ "204": { "description": "No Content" }, + "400": { + "description": "Bad Request" + }, "404": { "description": "Not Found" }, diff --git a/services/user-service/src/routes/index.ts b/services/user-service/src/routes/index.ts index 360b2644..e540ef92 100644 --- a/services/user-service/src/routes/index.ts +++ b/services/user-service/src/routes/index.ts @@ -41,38 +41,72 @@ indexRouter.get( indexRouter.put( "/:uid", function (req: express.Request, res: express.Response) { - userDatabaseFunctions - .updateUserByUid(req.params.uid, req.body) - .then((result) => { - res.status(200).json(result); - }) - .catch((error) => { - if (error.code === "P2025") { - res.status(404).end(); - } else { - // Server side error such as database not being available - res.status(500).end(); - } - }); + /** + * Need to check that header UID was not tampered with. + * + * Attack Scenario: + * 1) User 2 wants to edit profile of user 1. + * 2) This should be blocked by the gateway since path param uid = 1 and header uid = 1 + * but user 2 only has authentication token for user 2 + * 3) User 2 could change header uid = 2 to pass authentication, but retain path param uid = 1 + * to attempt to change user 1's data + * 4) Hence, need to check that param uid = header uid + */ + const pathUid = req.params.uid; + const headerUid = req.get("User-Id"); + if (pathUid !== headerUid) { + res.status(400).end(); + } else { + userDatabaseFunctions + .updateUserByUid(req.params.uid, req.body) + .then((result) => { + res.status(200).json(result); + }) + .catch((error) => { + if (error.code === "P2025") { + res.status(404).end(); + } else { + // Server side error such as database not being available + res.status(500).end(); + } + }); + } } ); indexRouter.delete( "/:uid", function (req: express.Request, res: express.Response) { - userDatabaseFunctions - .deleteUserByUid(req.params.uid) - .then(() => { - res.status(204).end(); - }) - .catch((error) => { - if (error.code === "P2025") { - res.status(404).end(); - } else { - // Server side error such as database not being available - res.status(500).end(); - } - }); + /** + * Need to check that header UID was not tampered with. + * + * Attack Scenario: + * 1) User 2 wants to edit profile of user 1. + * 2) This should be blocked by the gateway since path param uid = 1 and header uid = 1 + * but user 2 only has authentication token for user 2 + * 3) User 2 could change header uid = 2 to pass authentication, but retain path param uid = 1 + * to attempt to change user 1's data + * 4) Hence, need to check that param uid = header uid + */ + const pathUid = req.params.uid; + const headerUid = req.get("User-Id"); + if (pathUid !== headerUid) { + res.status(400).end(); + } else { + userDatabaseFunctions + .deleteUserByUid(req.params.uid) + .then(() => { + res.status(204).end(); + }) + .catch((error) => { + if (error.code === "P2025") { + res.status(404).end(); + } else { + // Server side error such as database not being available + res.status(500).end(); + } + }) + } } ); diff --git a/services/user-service/systemtest/app.test.ts b/services/user-service/systemtest/app.test.ts index 3c5069d0..eaf746c0 100644 --- a/services/user-service/systemtest/app.test.ts +++ b/services/user-service/systemtest/app.test.ts @@ -12,6 +12,8 @@ const updatedNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl" const updatePayload = { matchDifficulty: 1 }; +const userIdHeader = "User-Id"; + describe('/index', () => { describe('Sample App Workflow', () => { it('Step 1: Add user 1 to database should pass with status 201', async () => { @@ -28,9 +30,21 @@ describe('/index', () => { expect(response.body).toStrictEqual(fullNewUser); }) + it('Step 3a: Update details of user 1 from database by user 2 should fail with error 400', async () => { + // The function being tested + const response = await request(app) + .put('/api/user-service/1') + .set(userIdHeader, "2") + .send(updatePayload); + expect(response.status).toStrictEqual(400); + }) + it('Step 3: Update details of user 1 from database should pass', async () => { // The function being tested - const response = await request(app).put('/api/user-service/1').send(updatePayload); + const response = await request(app) + .put('/api/user-service/1') + .set(userIdHeader, "1") + .send(updatePayload); expect(response.status).toStrictEqual(200); expect(response.body).toStrictEqual(updatedNewUser); }) @@ -47,8 +61,19 @@ describe('/index', () => { expect(response.status).toStrictEqual(200); }) + it('Step 6a: Delete user 1 from database by user 2 should fail with status 400', async () => { + const response = await request(app) + .delete('/api/user-service/1') + .set(userIdHeader, "2") + .send(); + expect(response.status).toStrictEqual(400); + }) + it('Step 6: Delete user 1 from database', async () => { - const response = await request(app).delete('/api/user-service/1').send(); + const response = await request(app) + .delete('/api/user-service/1') + .set(userIdHeader, "1") + .send(); expect(response.status).toStrictEqual(204); }) @@ -60,13 +85,19 @@ describe('/index', () => { it('Step 8: Update details of now deleted user 1 should fail', async () => { // The function being tested - const response = await request(app).put('/api/user-service/1').send(updatePayload); + const response = await request(app) + .put('/api/user-service/1') + .set(userIdHeader, "1") + .send(updatePayload); expect(response.status).toStrictEqual(404); }) it('Step 9: Deleting the now deleted user 1 should fail', async () => { // The function being tested - const response = await request(app).delete('/api/user-service/1').send(); + const response = await request(app) + .delete('/api/user-service/1') + .set(userIdHeader, "1") + .send(); expect(response.status).toStrictEqual(404); }) }) diff --git a/services/user-service/test/routes/index.test.ts b/services/user-service/test/routes/index.test.ts index 8b3bfe06..6d2b5abd 100644 --- a/services/user-service/test/routes/index.test.ts +++ b/services/user-service/test/routes/index.test.ts @@ -9,6 +9,7 @@ import request from 'supertest'; vi.mock('../../src/db/functions') const app = express(); +const userIdHeader = "User-Id"; app.use(indexRouter); const fullNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: 0, @@ -93,7 +94,10 @@ describe('/index', () => { userDatabaseFunctionsMock.updateUserByUid.mockResolvedValueOnce(fullNewUser); // The function being tested - const response = await request(app).put('/1').send(); + const response = await request(app) + .put('/1') + .set(userIdHeader, "1") + .send(); expect(response.status).toStrictEqual(200); expect(response.body).toStrictEqual(fullNewUser); }) @@ -106,7 +110,10 @@ describe('/index', () => { })); // The function being tested - const response = await request(app).put('/1').send(); + const response = await request(app) + .put('/1') + .set(userIdHeader, "1") + .send(); expect(response.status).toStrictEqual(404); }) @@ -115,9 +122,25 @@ describe('/index', () => { userDatabaseFunctionsMock.updateUserByUid.mockRejectedValueOnce(new Error()); // The function being tested - const response = await request(app).put('/1').send(); + const response = await request(app) + .put('/1') + .set(userIdHeader, "1") + .send(); expect(response.status).toStrictEqual(500); }) + + it('[PUT] /1 but the header UID does not match path param UID', async () => { + + // Used to get back the user + userDatabaseFunctionsMock.updateUserByUid.mockResolvedValueOnce(fullNewUser); + + // The function being tested + const response = await request(app) + .put('/1') + .set(userIdHeader, "2") + .send(); + expect(response.status).toStrictEqual(400); + }) }) describe('deleteUserByUid', () => { @@ -127,7 +150,10 @@ describe('/index', () => { userDatabaseFunctionsMock.deleteUserByUid.mockResolvedValueOnce(fullNewUser); // The function being tested - const response = await request(app).delete('/1').send(); + const response = await request(app) + .delete('/1') + .set(userIdHeader, "1") + .send(); expect(response.status).toStrictEqual(204); }) @@ -139,7 +165,10 @@ describe('/index', () => { })); // The function being tested - const response = await request(app).delete('/1').send(); + const response = await request(app) + .delete('/1') + .set(userIdHeader, "1") + .send(); expect(response.status).toStrictEqual(404); }) @@ -148,8 +177,24 @@ describe('/index', () => { userDatabaseFunctionsMock.deleteUserByUid.mockRejectedValueOnce(new Error()); // The function being tested - const response = await request(app).delete('/1').send(); + const response = await request(app) + .delete('/1') + .set(userIdHeader, "1") + .send(); expect(response.status).toStrictEqual(500); }) + + it('[DELETE] /1 but the header UID does not match path param UID', async () => { + + // Used to get back the user + userDatabaseFunctionsMock.deleteUserByUid.mockResolvedValueOnce(fullNewUser); + + // The function being tested + const response = await request(app) + .delete('/1') + .set(userIdHeader, "2") + .send(); + expect(response.status).toStrictEqual(400); + }) }) }) From 36f73b0a5b1d0e5fbe4207dae2c28ac47c78e636 Mon Sep 17 00:00:00 2001 From: Tay Yi Hsuen Date: Thu, 26 Oct 2023 22:14:55 +0800 Subject: [PATCH 05/50] Fix websocket proxying bug (#183) Fixes #181 --- .../gateway-deployment.yaml | 12 ++++- .../gke-prod-manifests/gateway-service.yaml | 6 +++ .../prod-dockerfiles/Dockerfile.frontend-prod | 3 ++ docker-compose.yml | 6 ++- frontend/providers/MatchmakingProvider.tsx | 7 ++- .../src/gateway-address/gateway-address.ts | 12 ++--- frontend/src/hooks/useCollaboration.tsx | 5 +- services/gateway/README.md | 2 +- services/gateway/src/app.ts | 52 ++++++++++++++----- .../src/proxied_routes/proxied_routes.ts | 26 +++++----- services/gateway/src/proxy/proxy.ts | 12 +++-- 11 files changed, 100 insertions(+), 43 deletions(-) diff --git a/deployment/gke-prod-manifests/gateway-deployment.yaml b/deployment/gke-prod-manifests/gateway-deployment.yaml index 6c429131..db1cf683 100644 --- a/deployment/gke-prod-manifests/gateway-deployment.yaml +++ b/deployment/gke-prod-manifests/gateway-deployment.yaml @@ -24,8 +24,12 @@ spec: secretKeyRef: name: firebase-service-account key: firebase-service-account - - name: PORT + - name: HTTP_PROXY_PORT value: "4000" + - name: WS_MATCH_PROXY_PORT + value: "4002" + - name: WS_COLLABORATION_PROXY_PORT + value: "4003" - name: FRONTEND_ADDRESS value: "http://www.codeparty.org:3000" image: asia-southeast1-docker.pkg.dev/peerprep-group11-prod/codeparty-prod-images/gateway:latest @@ -34,6 +38,12 @@ spec: - containerPort: 4000 hostPort: 4000 protocol: TCP + - containerPort: 4002 + hostPort: 4002 + protocol: TCP + - containerPort: 4003 + hostPort: 4003 + protocol: TCP resources: # You must specify requests for CPU to autoscale # based on CPU utilization diff --git a/deployment/gke-prod-manifests/gateway-service.yaml b/deployment/gke-prod-manifests/gateway-service.yaml index 0bbcf2d9..519efc3f 100644 --- a/deployment/gke-prod-manifests/gateway-service.yaml +++ b/deployment/gke-prod-manifests/gateway-service.yaml @@ -10,6 +10,12 @@ spec: - name: "4000" port: 4000 targetPort: 4000 + - name: "4002" + port: 4002 + targetPort: 4002 + - name: "4003" + port: 4003 + targetPort: 4003 selector: io.kompose.service: gateway type: LoadBalancer diff --git a/deployment/prod-dockerfiles/Dockerfile.frontend-prod b/deployment/prod-dockerfiles/Dockerfile.frontend-prod index 8d47756c..7c305c02 100644 --- a/deployment/prod-dockerfiles/Dockerfile.frontend-prod +++ b/deployment/prod-dockerfiles/Dockerfile.frontend-prod @@ -24,6 +24,9 @@ ARG NEXT_PUBLIC_FRONTEND_FIREBASE_CONFIG_ARG ENV NEXT_PUBLIC_FRONTEND_FIREBASE_CONFIG=$NEXT_PUBLIC_FRONTEND_FIREBASE_CONFIG_ARG ENV NEXT_PUBLIC_GATEWAY_ADDRESS="http://api.codeparty.org:4000/" +ENV NEXT_PUBLIC_HTTP_PROXY_GATEWAY_ADDRESS="http://api.codeparty.org:4000/" +ENV NEXT_PUBLIC_WS_MATCH_PROXY_GATEWAY_ADDRESS="http://api.codeparty.org:4002/" +ENV NEXT_PUBLIC_WS_COLLABORATION_PROXY_GATEWAY_ADDRESS="http://api.codeparty.org:4003/" RUN yarn build diff --git a/docker-compose.yml b/docker-compose.yml index 67f21e31..614a6bec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,8 +79,12 @@ services: - ./services/gateway:/app/services/gateway ports: - "4000:4000" + - "4002:4002" + - "4003:4003" environment: - PORT: 4000 + HTTP_PROXY_PORT: 4000 + WS_MATCH_PROXY_PORT: 4002 + WS_COLLABORATION_PROXY_PORT: 4003 FIREBASE_SERVICE_ACCOUNT: ${FIREBASE_SERVICE_ACCOUNT} FRONTEND_ADDRESS: "http://localhost:3000" diff --git a/frontend/providers/MatchmakingProvider.tsx b/frontend/providers/MatchmakingProvider.tsx index eaeded30..aa5818a8 100644 --- a/frontend/providers/MatchmakingProvider.tsx +++ b/frontend/providers/MatchmakingProvider.tsx @@ -8,9 +8,9 @@ import React, { import { io, Socket } from "socket.io-client"; import { Match } from "@prisma/client"; import { AuthContext } from "@/contexts/AuthContext"; -import {matchSocketAddress} from "@/gateway-address/gateway-address"; +import {wsMatchProxyGatewayAddress} from "@/gateway-address/gateway-address"; -const SERVER_URL = matchSocketAddress; +const SERVER_URL = wsMatchProxyGatewayAddress; interface MatchmakingContextValue { socket: Socket | null; @@ -57,8 +57,7 @@ export const MatchmakingProvider: React.FC = ({ query: { username: generateRandomNumber() }, extraHeaders: { "User-Id-Token": token - }, - path: "/match/socket.io" + } }); setSocket(newSocket); newSocket.connect(); diff --git a/frontend/src/gateway-address/gateway-address.ts b/frontend/src/gateway-address/gateway-address.ts index f734bed1..a1ef68d1 100644 --- a/frontend/src/gateway-address/gateway-address.ts +++ b/frontend/src/gateway-address/gateway-address.ts @@ -5,10 +5,10 @@ * - Leave NEXT_PUBLIC_GATEWAY_ADDRESS empty for dev environments * - For prod, pass in a separate address to NEXT_PUBLIC_GATEWAY_ADDRESS */ -const gatewayAddress = process.env.NEXT_PUBLIC_GATEWAY_ADDRESS || "http://localhost:4000/" +const httpProxyGatewayAddress = process.env.NEXT_PUBLIC_HTTP_PROXY_GATEWAY_ADDRESS || "http://localhost:4000/"; +export const wsMatchProxyGatewayAddress = process.env.NEXT_PUBLIC_WS_MATCH_PROXY_GATEWAY_ADDRESS || "http://localhost:4002"; +export const wsCollaborationProxyGatewayAddress = process.env.NEXT_PUBLIC_WS_COLLABORATION_PROXY_GATEWAY_ADDRESS + || "http://localhost:4003"; -export const userApiPathAddress = gatewayAddress + "api/user-service/"; -export const questionApiPathAddress = gatewayAddress + "api/question-service/"; - -export const collaborationSocketAddress = gatewayAddress + "collaboration/socket.io"; -export const matchSocketAddress = gatewayAddress + "match/socket.io"; +export const userApiPathAddress = httpProxyGatewayAddress + "api/user-service/"; +export const questionApiPathAddress = httpProxyGatewayAddress + "api/question-service/"; diff --git a/frontend/src/hooks/useCollaboration.tsx b/frontend/src/hooks/useCollaboration.tsx index 7a470188..1f47adda 100644 --- a/frontend/src/hooks/useCollaboration.tsx +++ b/frontend/src/hooks/useCollaboration.tsx @@ -7,7 +7,7 @@ import { } from "../../../utils/shared-ot"; import { TextOp } from "ot-text-unicode"; import { Room, connect } from "twilio-video"; -import {collaborationSocketAddress} from "@/gateway-address/gateway-address"; +import {wsCollaborationProxyGatewayAddress} from "@/gateway-address/gateway-address"; import {AuthContext} from "@/contexts/AuthContext"; type UseCollaborationProps = { @@ -47,11 +47,10 @@ const useCollaboration = ({ roomId, userId, disableVideo }: UseCollaborationProp if (currentUser) { currentUser.getIdToken(true).then( (token) => { - const socketConnection = io(collaborationSocketAddress, { + const socketConnection = io(wsCollaborationProxyGatewayAddress, { extraHeaders: { "User-Id-Token": token }, - path: "/collaboration/socket.io" }); setSocket(socketConnection); diff --git a/services/gateway/README.md b/services/gateway/README.md index d4b21c52..e6cf6d05 100644 --- a/services/gateway/README.md +++ b/services/gateway/README.md @@ -24,7 +24,7 @@ The below code shows a sample route that is being proxied from the frontend to t } ``` -This code is part of the `proxied_routes` list in `src/proxied_routes/proxied_routes.ts` file. +This code is part of the `http_proxied_routes` list in `src/proxied_routes/proxied_routes.ts` file. Explanation: * `url` - The initial path. Assuming that the gateway address is `YYY://localhost:4000`, the frontend would call `YYY://localhost:4000/users` diff --git a/services/gateway/src/app.ts b/services/gateway/src/app.ts index 68464cd2..8b19d5f6 100644 --- a/services/gateway/src/app.ts +++ b/services/gateway/src/app.ts @@ -3,30 +3,58 @@ import cors from 'cors'; import { setupLogging } from "./logging/logging"; import { setupAdmin, setupUserIdMatch, setupIsLoggedIn } from "./auth/auth"; import { setupProxies } from "./proxy/proxy"; -import { proxied_routes } from "./proxied_routes/proxied_routes"; +import {http_proxied_routes, wsCollaborationProxiedRoutes, wsMatchProxiedRoutes} from "./proxied_routes/proxied_routes"; import {frontendAddress} from "./proxied_routes/service_names"; +import {createProxyMiddleware} from "http-proxy-middleware"; -const app : Express = express(); +const httpApp : Express = express(); +const wsMatchApp : Express = express(); +const wsCollaborationApp : Express = express(); + const corsOptions = { origin: frontendAddress, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"] } -const port : number = parseInt(process.env.PORT || "4000"); +const httpProxyPort : number = parseInt(process.env.HTTP_PROXY_PORT || "4000"); +const wsMatchProxyPort : number = parseInt(process.env.WS_MATCH_PROXY_PORT || "4002"); +const wsCollaborationProxyPort : number = parseInt(process.env.WS_COLLABORATION_PROXY_PORT || "4003"); -app.use(cors(corsOptions)) +httpApp.use(cors(corsOptions)); +wsMatchApp.use(cors(corsOptions)); +wsCollaborationApp.use(cors(corsOptions)); /** * WARNING: Do not add body parsing middleware to the Gateway. * Otherwise, proxying POST requests with request body would not work. */ -setupLogging(app); -setupIsLoggedIn(app, proxied_routes); -setupUserIdMatch(app, proxied_routes); -setupAdmin(app, proxied_routes); -setupProxies(app, proxied_routes); - -app.listen(port, () => { - console.log(`Example app listening at http://localhost:${port}`) +setupLogging(httpApp); +setupLogging(wsMatchApp); +setupLogging(wsCollaborationApp); + +setupIsLoggedIn(httpApp, http_proxied_routes); +setupIsLoggedIn(wsMatchApp, wsMatchProxiedRoutes); +setupIsLoggedIn(wsCollaborationApp, wsCollaborationProxiedRoutes); + + +setupUserIdMatch(httpApp, http_proxied_routes); +setupAdmin(httpApp, http_proxied_routes); +setupProxies(httpApp, http_proxied_routes); + +const wsMatchProxyMiddleware = createProxyMiddleware(wsMatchProxiedRoutes[0].proxy); +wsMatchApp.use(wsMatchProxiedRoutes[0].url, wsMatchProxyMiddleware); +const wsCollaborationProxyMiddleware = createProxyMiddleware(wsCollaborationProxiedRoutes[0].proxy); +wsCollaborationApp.use(wsCollaborationProxiedRoutes[0].url, wsCollaborationProxyMiddleware); + +httpApp.listen(httpProxyPort, () => { + console.log(`Gateway HTTP proxy listening on port ${httpProxyPort}`); +}) + +wsMatchApp.listen(wsMatchProxyPort, () => { + console.log(`Gateway WebSockets Match Proxy listening on port ${wsMatchProxyPort}`); +}) + +wsCollaborationApp.listen(wsCollaborationProxyPort, () => { + console.log(`Gateway WebSockets Collaboration Proxy listening on port ${wsCollaborationProxyPort}`); }) diff --git a/services/gateway/src/proxied_routes/proxied_routes.ts b/services/gateway/src/proxied_routes/proxied_routes.ts index c2b55ac8..1ffe07d9 100644 --- a/services/gateway/src/proxied_routes/proxied_routes.ts +++ b/services/gateway/src/proxied_routes/proxied_routes.ts @@ -7,7 +7,7 @@ import { userServiceAddress, } from "./service_names"; -export const proxied_routes: ProxiedRoute[] = [ +export const http_proxied_routes: ProxiedRoute[] = [ { url: "/api/user-service", admin_required_methods: [], // Empty, so no admin verification is done for all methods to the user-service @@ -58,28 +58,30 @@ export const proxied_routes: ProxiedRoute[] = [ changeOrigin: true, }, }, +]; + +export const wsMatchProxiedRoutes: ProxiedRoute[] = [ { - url: "/collaboration/socket.io", + url: "/", admin_required_methods: [], user_match_required_methods: [], // No need for exact user match here proxy: { - target: collaborationServiceAddress, + target: matchingServiceAddress, changeOrigin: true, - pathRewrite: { - "^/collaboration/socket.io": "socket.io", - }, + ws: true }, }, +] + +export const wsCollaborationProxiedRoutes: ProxiedRoute[] = [ { - url: "/match/socket.io", + url: "/", admin_required_methods: [], user_match_required_methods: [], // No need for exact user match here proxy: { - target: matchingServiceAddress, + target: collaborationServiceAddress, changeOrigin: true, - pathRewrite: { - "^/match/socket.io": "socket.io", - }, + ws: true }, }, -]; +] diff --git a/services/gateway/src/proxy/proxy.ts b/services/gateway/src/proxy/proxy.ts index a826fe56..484f7abc 100644 --- a/services/gateway/src/proxy/proxy.ts +++ b/services/gateway/src/proxy/proxy.ts @@ -2,7 +2,13 @@ import { createProxyMiddleware } from 'http-proxy-middleware'; import {Express} from "express"; export const setupProxies = (app : Express, routes : any[]) => { - routes.forEach(r => { - app.use(r.url, createProxyMiddleware(r.proxy)); - }) + var proxyMiddlewareArray : any[] = [] + for (let i = 0; i < routes.length; i++) { + const proxyMiddleware = createProxyMiddleware(routes[i].proxy); + app.use(routes[i].url, proxyMiddleware); + if (routes[i].proxy.ws) { + proxyMiddlewareArray.push(proxyMiddleware); + } + } + return proxyMiddlewareArray; } From 043f6ffc079dbbfc03467829c19ea5c5c721cbc1 Mon Sep 17 00:00:00 2001 From: "YIHSUEN\\Yi Hsuen" Date: Thu, 26 Oct 2023 22:46:02 +0800 Subject: [PATCH 06/50] Fix missing env variables in Docker Compose --- deployment/prod-dockerfiles/Dockerfile.frontend-prod | 1 - docker-compose.yml | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/deployment/prod-dockerfiles/Dockerfile.frontend-prod b/deployment/prod-dockerfiles/Dockerfile.frontend-prod index 7c305c02..a23eb2f9 100644 --- a/deployment/prod-dockerfiles/Dockerfile.frontend-prod +++ b/deployment/prod-dockerfiles/Dockerfile.frontend-prod @@ -23,7 +23,6 @@ RUN yarn prisma generate ARG NEXT_PUBLIC_FRONTEND_FIREBASE_CONFIG_ARG ENV NEXT_PUBLIC_FRONTEND_FIREBASE_CONFIG=$NEXT_PUBLIC_FRONTEND_FIREBASE_CONFIG_ARG -ENV NEXT_PUBLIC_GATEWAY_ADDRESS="http://api.codeparty.org:4000/" ENV NEXT_PUBLIC_HTTP_PROXY_GATEWAY_ADDRESS="http://api.codeparty.org:4000/" ENV NEXT_PUBLIC_WS_MATCH_PROXY_GATEWAY_ADDRESS="http://api.codeparty.org:4002/" ENV NEXT_PUBLIC_WS_COLLABORATION_PROXY_GATEWAY_ADDRESS="http://api.codeparty.org:4003/" diff --git a/docker-compose.yml b/docker-compose.yml index 614a6bec..215f3800 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -99,5 +99,7 @@ services: ports: - "3000:3000" environment: - NEXT_PUBLIC_GATEWAY_ADDRESS: "http://localhost:4000/" + NEXT_PUBLIC_HTTP_PROXY_GATEWAY_ADDRESS: "http://localhost:4000/" + NEXT_PUBLIC_WS_MATCH_PROXY_GATEWAY_ADDRESS: "http://localhost:4002/" + NEXT_PUBLIC_WS_COLLABORATION_PROXY_GATEWAY_ADDRESS: "http://localhost:4003/" NEXT_PUBLIC_FRONTEND_FIREBASE_CONFIG: ${NEXT_PUBLIC_FRONTEND_FIREBASE_CONFIG} From 38eefe3efa8cc473647bebb006cc07e0aeece476 Mon Sep 17 00:00:00 2001 From: "YIHSUEN\\Yi Hsuen" Date: Thu, 26 Oct 2023 22:57:53 +0800 Subject: [PATCH 07/50] Add HTTPS support and reconfigure ingress --- deployment/gke-prod-manifests/frontend-ingress.yaml | 13 +++++++++++++ deployment/gke-prod-manifests/frontend-service.yaml | 1 - .../gke-prod-manifests/gateway-deployment.yaml | 2 +- .../gke-prod-manifests/gateway-http-ingress.yaml | 13 +++++++++++++ deployment/gke-prod-manifests/gateway-service.yaml | 1 - .../gateway-wscollaboration-ingress.yaml | 13 +++++++++++++ .../gke-prod-manifests/gateway-wsmatch-ingress.yaml | 13 +++++++++++++ deployment/gke-prod-manifests/gke-managed-cert.yaml | 11 +++++++++++ .../prod-dockerfiles/Dockerfile.frontend-prod | 6 +++--- 9 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 deployment/gke-prod-manifests/frontend-ingress.yaml create mode 100644 deployment/gke-prod-manifests/gateway-http-ingress.yaml create mode 100644 deployment/gke-prod-manifests/gateway-wscollaboration-ingress.yaml create mode 100644 deployment/gke-prod-manifests/gateway-wsmatch-ingress.yaml create mode 100644 deployment/gke-prod-manifests/gke-managed-cert.yaml diff --git a/deployment/gke-prod-manifests/frontend-ingress.yaml b/deployment/gke-prod-manifests/frontend-ingress.yaml new file mode 100644 index 00000000..86a8e8a7 --- /dev/null +++ b/deployment/gke-prod-manifests/frontend-ingress.yaml @@ -0,0 +1,13 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: frontend-ingress + annotations: + networking.gke.io/managed-certificates: gke-managed-cert + kubernetes.io/ingress.class: "gce" +spec: + defaultBackend: + service: + name: frontend-service + port: + number: 3000 diff --git a/deployment/gke-prod-manifests/frontend-service.yaml b/deployment/gke-prod-manifests/frontend-service.yaml index 9f0ca682..a72a5a12 100644 --- a/deployment/gke-prod-manifests/frontend-service.yaml +++ b/deployment/gke-prod-manifests/frontend-service.yaml @@ -12,6 +12,5 @@ spec: targetPort: 3000 selector: io.kompose.service: frontend - type: LoadBalancer status: loadBalancer: {} diff --git a/deployment/gke-prod-manifests/gateway-deployment.yaml b/deployment/gke-prod-manifests/gateway-deployment.yaml index db1cf683..37d8493b 100644 --- a/deployment/gke-prod-manifests/gateway-deployment.yaml +++ b/deployment/gke-prod-manifests/gateway-deployment.yaml @@ -31,7 +31,7 @@ spec: - name: WS_COLLABORATION_PROXY_PORT value: "4003" - name: FRONTEND_ADDRESS - value: "http://www.codeparty.org:3000" + value: "https://www.codeparty.org" image: asia-southeast1-docker.pkg.dev/peerprep-group11-prod/codeparty-prod-images/gateway:latest name: gateway ports: diff --git a/deployment/gke-prod-manifests/gateway-http-ingress.yaml b/deployment/gke-prod-manifests/gateway-http-ingress.yaml new file mode 100644 index 00000000..2af19c15 --- /dev/null +++ b/deployment/gke-prod-manifests/gateway-http-ingress.yaml @@ -0,0 +1,13 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: gateway-http-ingress + annotations: + networking.gke.io/managed-certificates: gke-managed-cert + kubernetes.io/ingress.class: "gce" +spec: + defaultBackend: + service: + name: frontend-service + port: + number: 4000 diff --git a/deployment/gke-prod-manifests/gateway-service.yaml b/deployment/gke-prod-manifests/gateway-service.yaml index 519efc3f..64815258 100644 --- a/deployment/gke-prod-manifests/gateway-service.yaml +++ b/deployment/gke-prod-manifests/gateway-service.yaml @@ -18,6 +18,5 @@ spec: targetPort: 4003 selector: io.kompose.service: gateway - type: LoadBalancer status: loadBalancer: {} diff --git a/deployment/gke-prod-manifests/gateway-wscollaboration-ingress.yaml b/deployment/gke-prod-manifests/gateway-wscollaboration-ingress.yaml new file mode 100644 index 00000000..72714ddc --- /dev/null +++ b/deployment/gke-prod-manifests/gateway-wscollaboration-ingress.yaml @@ -0,0 +1,13 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: gateway-wscollaboration-ingress + annotations: + networking.gke.io/managed-certificates: gke-managed-cert + kubernetes.io/ingress.class: "gce" +spec: + defaultBackend: + service: + name: gateway-service + port: + number: 4003 diff --git a/deployment/gke-prod-manifests/gateway-wsmatch-ingress.yaml b/deployment/gke-prod-manifests/gateway-wsmatch-ingress.yaml new file mode 100644 index 00000000..3f6c2064 --- /dev/null +++ b/deployment/gke-prod-manifests/gateway-wsmatch-ingress.yaml @@ -0,0 +1,13 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: gateway-wsmatch-ingress + annotations: + networking.gke.io/managed-certificates: gke-managed-cert + kubernetes.io/ingress.class: "gce" +spec: + defaultBackend: + service: + name: gateway-service + port: + number: 4002 diff --git a/deployment/gke-prod-manifests/gke-managed-cert.yaml b/deployment/gke-prod-manifests/gke-managed-cert.yaml new file mode 100644 index 00000000..4390e533 --- /dev/null +++ b/deployment/gke-prod-manifests/gke-managed-cert.yaml @@ -0,0 +1,11 @@ +apiVersion: networking.gke.io/v1 +kind: ManagedCertificate +metadata: + name: gke-managed-cert +spec: + domains: + - codeparty.org + - www.codeparty.org + - api.codeparty.org + - wsmatch.codeparty.org + - wscollab.codeparty.org diff --git a/deployment/prod-dockerfiles/Dockerfile.frontend-prod b/deployment/prod-dockerfiles/Dockerfile.frontend-prod index a23eb2f9..d8e4826b 100644 --- a/deployment/prod-dockerfiles/Dockerfile.frontend-prod +++ b/deployment/prod-dockerfiles/Dockerfile.frontend-prod @@ -23,9 +23,9 @@ RUN yarn prisma generate ARG NEXT_PUBLIC_FRONTEND_FIREBASE_CONFIG_ARG ENV NEXT_PUBLIC_FRONTEND_FIREBASE_CONFIG=$NEXT_PUBLIC_FRONTEND_FIREBASE_CONFIG_ARG -ENV NEXT_PUBLIC_HTTP_PROXY_GATEWAY_ADDRESS="http://api.codeparty.org:4000/" -ENV NEXT_PUBLIC_WS_MATCH_PROXY_GATEWAY_ADDRESS="http://api.codeparty.org:4002/" -ENV NEXT_PUBLIC_WS_COLLABORATION_PROXY_GATEWAY_ADDRESS="http://api.codeparty.org:4003/" +ENV NEXT_PUBLIC_HTTP_PROXY_GATEWAY_ADDRESS="https://api.codeparty.org/" +ENV NEXT_PUBLIC_WS_MATCH_PROXY_GATEWAY_ADDRESS="https://wsmatch.codeparty.org" +ENV NEXT_PUBLIC_WS_COLLABORATION_PROXY_GATEWAY_ADDRESS="https://wscollab.codeparty.org" RUN yarn build From f87dbb8f8472c1ac91d0763267550b8045abfa40 Mon Sep 17 00:00:00 2001 From: "YIHSUEN\\Yi Hsuen" Date: Thu, 26 Oct 2023 23:41:27 +0800 Subject: [PATCH 08/50] Fix service name errors --- deployment/gke-prod-manifests/frontend-ingress.yaml | 2 +- deployment/gke-prod-manifests/gateway-http-ingress.yaml | 2 +- .../gke-prod-manifests/gateway-wscollaboration-ingress.yaml | 2 +- deployment/gke-prod-manifests/gateway-wsmatch-ingress.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deployment/gke-prod-manifests/frontend-ingress.yaml b/deployment/gke-prod-manifests/frontend-ingress.yaml index 86a8e8a7..c99b9e91 100644 --- a/deployment/gke-prod-manifests/frontend-ingress.yaml +++ b/deployment/gke-prod-manifests/frontend-ingress.yaml @@ -8,6 +8,6 @@ metadata: spec: defaultBackend: service: - name: frontend-service + name: frontend port: number: 3000 diff --git a/deployment/gke-prod-manifests/gateway-http-ingress.yaml b/deployment/gke-prod-manifests/gateway-http-ingress.yaml index 2af19c15..313b3edc 100644 --- a/deployment/gke-prod-manifests/gateway-http-ingress.yaml +++ b/deployment/gke-prod-manifests/gateway-http-ingress.yaml @@ -8,6 +8,6 @@ metadata: spec: defaultBackend: service: - name: frontend-service + name: frontend port: number: 4000 diff --git a/deployment/gke-prod-manifests/gateway-wscollaboration-ingress.yaml b/deployment/gke-prod-manifests/gateway-wscollaboration-ingress.yaml index 72714ddc..b3900773 100644 --- a/deployment/gke-prod-manifests/gateway-wscollaboration-ingress.yaml +++ b/deployment/gke-prod-manifests/gateway-wscollaboration-ingress.yaml @@ -8,6 +8,6 @@ metadata: spec: defaultBackend: service: - name: gateway-service + name: gateway port: number: 4003 diff --git a/deployment/gke-prod-manifests/gateway-wsmatch-ingress.yaml b/deployment/gke-prod-manifests/gateway-wsmatch-ingress.yaml index 3f6c2064..5aea8575 100644 --- a/deployment/gke-prod-manifests/gateway-wsmatch-ingress.yaml +++ b/deployment/gke-prod-manifests/gateway-wsmatch-ingress.yaml @@ -8,6 +8,6 @@ metadata: spec: defaultBackend: service: - name: gateway-service + name: gateway port: number: 4002 From 86ded76d71f617bf3d45ac1ca154de5fc2c579de Mon Sep 17 00:00:00 2001 From: "YIHSUEN\\Yi Hsuen" Date: Thu, 26 Oct 2023 23:43:23 +0800 Subject: [PATCH 09/50] Fix gateway being named frontend --- deployment/gke-prod-manifests/gateway-http-ingress.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployment/gke-prod-manifests/gateway-http-ingress.yaml b/deployment/gke-prod-manifests/gateway-http-ingress.yaml index 313b3edc..cb6434ae 100644 --- a/deployment/gke-prod-manifests/gateway-http-ingress.yaml +++ b/deployment/gke-prod-manifests/gateway-http-ingress.yaml @@ -8,6 +8,6 @@ metadata: spec: defaultBackend: service: - name: frontend + name: gateway port: number: 4000 From 90af990f845dcc2d903c8c3a641c2dde7a457cb6 Mon Sep 17 00:00:00 2001 From: Lee Chun Wei <47494777+chunweii@users.noreply.github.com> Date: Fri, 27 Oct 2023 02:59:49 +0800 Subject: [PATCH 10/50] Add server-side search for question title and filtering by difficulty (#184) Fixes #168 --- .../src/components/questions/data-table.tsx | 89 ++++++++++++++++--- 1 file changed, 78 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/questions/data-table.tsx b/frontend/src/components/questions/data-table.tsx index ee6cf166..617f0113 100644 --- a/frontend/src/components/questions/data-table.tsx +++ b/frontend/src/components/questions/data-table.tsx @@ -50,7 +50,7 @@ export function DataTable({ columns, isEditable = false, }: DataTableProps) { - const [sorting, setSorting] = useState([]); + const [sorting, setSorting] = useState([{"id": "title", "desc": false}]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); const { user: currentUser, authIsReady } = useContext(AuthContext); @@ -60,19 +60,37 @@ export function DataTable({ const [{pageIndex, pageSize}, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const [searchTitle, setSearchTitle] = useState(""); + const [filteredDifficulty, setFilteredDifficulty] = useState({"easy": true, "medium": true, "hard": true}); + const [localFilteredDifficulty, setLocalFilteredDifficulty] = useState({"easy": true, "medium": true, "hard": true}); + const fetchDataOptions = { pageIndex, pageSize, + searchTitle, + filteredDifficulty, + sorting, uid: currentUser?.uid ?? null, }; const dataQuery = useQuery({ queryKey: ["questions", fetchDataOptions], queryFn: async ({queryKey}) => { - const {uid, pageIndex, pageSize} = queryKey[1] as {uid: string | null, pageIndex: number, pageSize: number}; + const {uid, pageIndex, pageSize, searchTitle, filteredDifficulty, sorting} = queryKey[1] as typeof fetchDataOptions; if (!uid) throw new Error("Unauthenticated user"); setLoading(true); - const response = await fetchQuestions(currentUser, pageIndex, pageSize, (isEditable ? {"author": uid}: {})); + let conditions: any = {"difficulty": Object.keys(filteredDifficulty).filter((key) => (filteredDifficulty as any)[key])}; + if (isEditable) { + conditions = {...conditions, "author": uid}; + } + if (searchTitle) { + conditions = {...conditions, "searchTitle": searchTitle}; + } + if (sorting.length > 0) { + const sortObj = sorting.map(sortState => ({[sortState.id]: sortState.desc ? -1 : 1})).reduce((acc, curr) => ({...acc, ...curr}), {}); + conditions = {...conditions, "sort": sortObj}; + } + const response = await fetchQuestions(currentUser, pageIndex, pageSize, conditions); setLoading(false) if (pageIndex > maxPage.current.maxFetchedPage) { maxPage.current.maxFetchedPage = pageIndex; @@ -110,22 +128,71 @@ export function DataTable({ columnVisibility, pagination }, + manualSorting: true, manualPagination: true, onPaginationChange: setPagination, pageCount: maxPage.current.isMax ? maxPage.current.maxFetchedPage + 1 : -1, }); + + return (
- - table.getColumn("title")?.setFilterValue(event.target.value) - } - className="max-w-sm" - /> +
+ { + if (e.key === 'Enter') { + setSearchTitle((e.target as EventTarget & HTMLInputElement).value); + // reset pagination + setPagination({pageIndex: 0, pageSize: 10}); + maxPage.current = {maxFetchedPage: -1, isMax: false}; + } + }} + className="max-w-sm" + /> + { + if (!open) { + if (JSON.stringify(localFilteredDifficulty) !== JSON.stringify(filteredDifficulty)) { + setFilteredDifficulty(localFilteredDifficulty); + // reset pagination + setPagination({pageIndex: 0, pageSize: 10}); + maxPage.current = {maxFetchedPage: -1, isMax: false}; + } + } + }}> + + + + + {["easy", "medium", "hard"] + .map((difficulty) => { + return ( + + setLocalFilteredDifficulty(prevValue => { + const newValue = {...prevValue}; + (newValue as any)[difficulty] = !!value; + return newValue; + }) + } + onSelect={e => e.preventDefault()} + > + {difficulty} + + ); + })} + + +
+ - - No framework found. + + No language found. - {frameworks.map((framework) => ( + {languages.map((language) => ( { - setValue(currentValue === value ? "" : currentValue); + setValue( + currentValue === value ? "" : currentValue + ); setOpen(false); }} > - {framework.label} + {language.label} ))} @@ -183,14 +164,16 @@ export default function Interviews() {
-
- - Leaderboard - + Leaderboard
From 700aadad7079177ded96a817663fb90fba7b5d89 Mon Sep 17 00:00:00 2001 From: Lee Chun Wei <47494777+chunweii@users.noreply.github.com> Date: Mon, 30 Oct 2023 23:15:27 +0800 Subject: [PATCH 25/50] Check if user is admin on frontend (#193) TODO: - Only admins can contribute, edit and delete questions - Normal users should be able to only see the list of questions and should not be able to access edit question page - User profile management stuff??? (Not too familiar, pls help) --- frontend/src/components/common/navbar.tsx | 3 ++- frontend/src/contexts/AuthContext.tsx | 16 +++++++++------- frontend/src/pages/questions/[id]/edit.tsx | 6 +++--- frontend/src/pages/questions/index.tsx | 13 +++++++++---- frontend/src/pages/questions/new.tsx | 13 ++++++++++++- 5 files changed, 35 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/common/navbar.tsx b/frontend/src/components/common/navbar.tsx index 3af0046a..5581216c 100644 --- a/frontend/src/components/common/navbar.tsx +++ b/frontend/src/components/common/navbar.tsx @@ -23,7 +23,7 @@ enum TabsOptions { } export default function Navbar() { - const { user: currentUser, authIsReady } = useContext(AuthContext); + const { user: currentUser, authIsReady, isAdmin } = useContext(AuthContext); const [activeTab, setActiveTab] = useState(TabsOptions.NULL); const { login } = useLogin(); @@ -79,6 +79,7 @@ export default function Navbar() { )} + {isAdmin &&

Admin Page

} {!currentUser && (
-
+ {isAdmin &&
My Contributed Questions -
+
}
All Questions - +
diff --git a/frontend/src/pages/questions/new.tsx b/frontend/src/pages/questions/new.tsx index a351cee4..1a4094e1 100644 --- a/frontend/src/pages/questions/new.tsx +++ b/frontend/src/pages/questions/new.tsx @@ -8,12 +8,14 @@ import * as z from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import QuestionsForm, { formSchema } from "./_form"; -import { useState } from "react"; +import { useContext, useEffect, useState } from "react"; import { useQuestions } from "../../hooks/useQuestions"; +import { AuthContext } from "@/contexts/AuthContext"; export default function NewQuestion() { const {postNewQuestion} = useQuestions(); const [loading, setLoading] = useState(false); + const { user: currentUser, authIsReady, isAdmin } = useContext(AuthContext); const router = useRouter(); const form = useForm>({ resolver: zodResolver(formSchema), @@ -27,6 +29,15 @@ export default function NewQuestion() { }, }); + useEffect(() => { + if (!authIsReady) { + return; + } + if (!currentUser || !isAdmin) { + router.push("/"); + } + }, [authIsReady, currentUser]) + function onSubmit(values: z.infer) { setLoading(true); From e01b270b828f2a31d3d758c45bcde38268fa8dc5 Mon Sep 17 00:00:00 2001 From: Tay Yi Hsuen Date: Tue, 31 Oct 2023 23:24:27 +0800 Subject: [PATCH 26/50] Add success condition to production workflow run (#209) --- .github/workflows/production.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 88c7ffe7..b16d2016 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -30,6 +30,7 @@ jobs: name: Setup, Build, Publish, and Deploy runs-on: ubuntu-latest environment: production + if: ${{ github.event.workflow_run.conclusion == 'success' }} permissions: contents: 'read' id-token: 'write' From b2f5b41ca5608af8cc54693de9ce46d7fa51fb39 Mon Sep 17 00:00:00 2001 From: Ong Jun Xiong Date: Wed, 1 Nov 2023 00:45:39 +0800 Subject: [PATCH 27/50] add useHistory hook --- frontend/src/hooks/useHistory.tsx | 31 +++++++++++++++ frontend/src/pages/api/historyHandler.ts | 48 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 frontend/src/hooks/useHistory.tsx create mode 100644 frontend/src/pages/api/historyHandler.ts diff --git a/frontend/src/hooks/useHistory.tsx b/frontend/src/hooks/useHistory.tsx new file mode 100644 index 00000000..cdb92498 --- /dev/null +++ b/frontend/src/hooks/useHistory.tsx @@ -0,0 +1,31 @@ +import { useContext } from "react"; +import { AuthContext } from "@/contexts/AuthContext"; +import { + getAttemptsOfUser, + createAttemptOfUser, +} from "./../pages/api/historyHandler"; + +type AttemptData = { + uid: string; + question_id: string; + answer: string; + solved: boolean; // just set everything as false for now (no need to check) +}; + +export const useHistory = () => { + const { user: currentUser, authIsReady } = useContext(AuthContext); + + const fetchAttempts = async (uid: string) => { + if (authIsReady) { + return getAttemptsOfUser(currentUser, uid); + } + }; + + const postAttempt = async (data: AttemptData) => { + if (authIsReady) { + return createAttemptOfUser(currentUser, data); + } + }; + + return { fetchAttempts, postAttempt }; +}; diff --git a/frontend/src/pages/api/historyHandler.ts b/frontend/src/pages/api/historyHandler.ts new file mode 100644 index 00000000..b0b0fb9f --- /dev/null +++ b/frontend/src/pages/api/historyHandler.ts @@ -0,0 +1,48 @@ +import { userApiPathAddress } from "@/gateway-address/gateway-address"; + +export const getAttemptsOfUser = async (user: any, uid: string) => { + try { + const url = `${userApiPathAddress}api/${uid}/attempts`; + const idToken = await user.getIdToken(true); + + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + "User-Id-Token": idToken, + }, + }); + + if (!response.ok) { + throw new Error(`Unable to get attempts: ${await response.text()}`); + } + return response.json(); + } catch (error) { + console.error("There was an error fetching the attempts", error); + throw error; + } +}; + +export const createAttemptOfUser = async (user: any, data: any) => { + try { + const url = `${userApiPathAddress}api/attempt`; + const idToken = await user.getIdToken(true); + + const response = await fetch(url, { + method: "POST", + body: JSON.stringify(data), + headers: { + "Content-Type": "application/json", + "User-Id-Token": idToken, + }, + }); + + if (!response.ok) { + throw new Error(`Unable to create attempt: ${await response.text()}`); + } + return response.json(); + } catch (error) { + console.error("There was an error creating the attempt", error); + throw error; + } +}; From e09d0c54b305b68a2c5b0f8373187375e2a1a4f2 Mon Sep 17 00:00:00 2001 From: Gabriel Goh <77230723+gycgabriel@users.noreply.github.com> Date: Wed, 1 Nov 2023 00:49:21 +0800 Subject: [PATCH 28/50] Display question and swap question in collaboration rooms (#185) Fix #80, fix #149, fix #216 --- frontend/package.json | 2 +- frontend/src/components/room/description.tsx | 15 +- .../src/gateway-address/gateway-address.ts | 18 +- frontend/src/hooks/useCollaboration.tsx | 151 +++++++------- frontend/src/hooks/useMatch.tsx | 29 +++ frontend/src/hooks/useQuestions.ts | 14 +- frontend/src/pages/api/matchHandler.ts | 69 +++++++ frontend/src/pages/interviews/match-found.tsx | 1 + frontend/src/pages/room/[id].tsx | 189 ++++++++++++++---- .../src/providers/MatchmakingProvider.tsx | 41 ++-- frontend/src/types/MatchTypes.ts | 9 + prisma/schema.prisma | 2 +- services/admin-service/package.json | 8 +- services/collaboration-service/package.json | 3 +- .../collaboration-service/src/db/prisma-db.ts | 1 + services/gateway/package.json | 2 +- services/gateway/src/app.ts | 65 +++--- services/matching-service/package.json | 4 +- services/matching-service/src/app.ts | 2 +- .../src/controllers/matchingController.ts | 91 ++++++++- .../matching-service/src/questionAdapter.ts | 50 +++++ .../src/routes/matchingRoutes.ts | 15 +- .../matching-service/src/swagger-output.json | 6 +- services/matching-service/swagger-doc-gen.ts | 7 +- services/question-service/package.json | 4 +- .../question-service/src/swagger-output.json | 2 +- services/question-service/swagger-doc-gen.ts | 16 +- services/user-service/package.json | 10 +- start-app-no-docker.sh | 16 +- start-app-with-docker.sh | 4 +- 30 files changed, 619 insertions(+), 227 deletions(-) create mode 100644 frontend/src/hooks/useMatch.tsx create mode 100644 frontend/src/pages/api/matchHandler.ts create mode 100644 frontend/src/types/MatchTypes.ts create mode 100644 services/matching-service/src/questionAdapter.ts diff --git a/frontend/package.json b/frontend/package.json index df5c5b5c..5f6e3ee9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "lint": "next lint", - "dev:local": "dotenv -e ../.env -- yarn dev", + "dev:local": "dotenv -e ../.env -- yarnpkg dev", "dev": "next dev", "build": "next build", "start": "next start -H 0.0.0.0", diff --git a/frontend/src/components/room/description.tsx b/frontend/src/components/room/description.tsx index c5d488bf..7bfb155d 100644 --- a/frontend/src/components/room/description.tsx +++ b/frontend/src/components/room/description.tsx @@ -9,14 +9,18 @@ import { TypographyH2, TypographySmall } from "../ui/typography"; type DescriptionProps = { question: Question; className?: string; + onSwapQuestionClick?: () => void; }; export default function Description({ question, className, + onSwapQuestionClick, }: DescriptionProps) { return ( - +
{question.title} @@ -26,7 +30,9 @@ export default function Description({
- +
{question.topics.map((tag) => ( @@ -37,7 +43,10 @@ export default function Description({
-
+
diff --git a/frontend/src/gateway-address/gateway-address.ts b/frontend/src/gateway-address/gateway-address.ts index a1ef68d1..0ce152b0 100644 --- a/frontend/src/gateway-address/gateway-address.ts +++ b/frontend/src/gateway-address/gateway-address.ts @@ -5,10 +5,18 @@ * - Leave NEXT_PUBLIC_GATEWAY_ADDRESS empty for dev environments * - For prod, pass in a separate address to NEXT_PUBLIC_GATEWAY_ADDRESS */ -const httpProxyGatewayAddress = process.env.NEXT_PUBLIC_HTTP_PROXY_GATEWAY_ADDRESS || "http://localhost:4000/"; -export const wsMatchProxyGatewayAddress = process.env.NEXT_PUBLIC_WS_MATCH_PROXY_GATEWAY_ADDRESS || "http://localhost:4002"; -export const wsCollaborationProxyGatewayAddress = process.env.NEXT_PUBLIC_WS_COLLABORATION_PROXY_GATEWAY_ADDRESS - || "http://localhost:4003"; +const httpProxyGatewayAddress = + process.env.NEXT_PUBLIC_HTTP_PROXY_GATEWAY_ADDRESS || + "http://localhost:4000/"; +export const wsMatchProxyGatewayAddress = + process.env.NEXT_PUBLIC_WS_MATCH_PROXY_GATEWAY_ADDRESS || + "http://localhost:4002"; +export const wsCollaborationProxyGatewayAddress = + process.env.NEXT_PUBLIC_WS_COLLABORATION_PROXY_GATEWAY_ADDRESS || + "http://localhost:4003"; export const userApiPathAddress = httpProxyGatewayAddress + "api/user-service/"; -export const questionApiPathAddress = httpProxyGatewayAddress + "api/question-service/"; +export const questionApiPathAddress = + httpProxyGatewayAddress + "api/question-service/"; +export const matchApiPathAddress = + httpProxyGatewayAddress + "api/matching-service/"; diff --git a/frontend/src/hooks/useCollaboration.tsx b/frontend/src/hooks/useCollaboration.tsx index 561b171b..321135a0 100644 --- a/frontend/src/hooks/useCollaboration.tsx +++ b/frontend/src/hooks/useCollaboration.tsx @@ -1,4 +1,4 @@ -import {useEffect, useState, useRef, use, useContext} from "react"; +import { useEffect, useState, useRef, use, useContext } from "react"; import { io, Socket } from "socket.io-client"; import { debounce } from "lodash"; import { @@ -7,8 +7,8 @@ import { } from "../../../utils/shared-ot"; import { TextOp } from "ot-text-unicode"; import { Room, connect } from "twilio-video"; -import {wsCollaborationProxyGatewayAddress} from "@/gateway-address/gateway-address"; -import {AuthContext} from "@/contexts/AuthContext"; +import { wsCollaborationProxyGatewayAddress } from "@/gateway-address/gateway-address"; +import { AuthContext } from "@/contexts/AuthContext"; type UseCollaborationProps = { roomId: string; @@ -26,13 +26,18 @@ enum SocketEvents { var vers = 0; -const useCollaboration = ({ roomId, userId, disableVideo }: UseCollaborationProps) => { +const useCollaboration = ({ + roomId, + userId, + disableVideo, +}: UseCollaborationProps) => { const [socket, setSocket] = useState(null); const [text, setText] = useState("#Write your solution here"); const [cursor, setCursor] = useState( "#Write your solution here".length ); const [room, setRoom] = useState(null); // twilio room + const [questionId, setQuestionId] = useState(""); const textRef = useRef(text); const cursorRef = useRef(cursor); const prevCursorRef = useRef(cursor); @@ -40,92 +45,94 @@ const useCollaboration = ({ roomId, userId, disableVideo }: UseCollaborationProp const awaitingAck = useRef(false); // ack from sending update const awaitingSync = useRef(false); // synced with server const twilioTokenRef = useRef(""); - const questionId = "1"; const { user: currentUser, authIsReady } = useContext(AuthContext); useEffect(() => { if (currentUser) { - currentUser.getIdToken(true).then( - (token) => { - const socketConnection = io(wsCollaborationProxyGatewayAddress, { - extraHeaders: { - "User-Id-Token": token - }, - }); - setSocket(socketConnection); - - socketConnection.emit(SocketEvents.ROOM_JOIN, roomId, userId); + currentUser.getIdToken(true).then((token) => { + const socketConnection = io(wsCollaborationProxyGatewayAddress, { + extraHeaders: { + "User-Id-Token": token, + }, + }); + setSocket(socketConnection); + + socketConnection.emit(SocketEvents.ROOM_JOIN, roomId, userId); + if (questionId !== "") { socketConnection.emit(SocketEvents.QUESTION_SET, questionId); + } - socketConnection.on("twilio-token", (token: string) => { - twilioTokenRef.current = token; - if (disableVideo) return; - connect(token, { - name: roomId, audio: true, - video: {width: 640, height: 480, frameRate: 24} - }).then((room) => { + socketConnection.on("twilio-token", (token: string) => { + twilioTokenRef.current = token; + if (disableVideo) return; + connect(token, { + name: roomId, + audio: true, + video: { width: 640, height: 480, frameRate: 24 }, + }) + .then((room) => { console.log("Connected to Room"); - room.localParticipant.videoTracks.forEach(publication => { + room.localParticipant.videoTracks.forEach((publication) => { publication.track.disable(); }); - room.localParticipant.audioTracks.forEach(publication => { + room.localParticipant.audioTracks.forEach((publication) => { publication.track.disable(); }); setRoom(room); - }).catch(err => { + }) + .catch((err) => { console.log(err, token, userId, roomId); }); - }); - - socketConnection.on( - SocketEvents.ROOM_UPDATE, - ({ - version, - text, - cursor, - }: { - version: number; - text: string; - cursor: number | undefined | null; - }) => { - prevCursorRef.current = cursorRef.current; - console.log("prevCursor: " + prevCursorRef.current); - - console.log("cursor: " + cursor); - - console.log("Update vers to " + version); - vers = version; - - if (awaitingAck.current) return; - - textRef.current = text; - prevTextRef.current = text; - setText(text); - if (cursor && cursor > -1) { - console.log("Update cursor to " + cursor); - cursorRef.current = cursor; - setCursor(cursor); - } else { - cursorRef.current = prevCursorRef.current; - cursor = prevCursorRef.current; - console.log("Update cursor to " + prevCursorRef.current); - setCursor(prevCursorRef.current); - } - awaitingSync.current = false; + }); + + socketConnection.on( + SocketEvents.ROOM_UPDATE, + ({ + version, + text, + cursor, + }: { + version: number; + text: string; + cursor: number | undefined | null; + }) => { + prevCursorRef.current = cursorRef.current; + console.log("prevCursor: " + prevCursorRef.current); + + console.log("cursor: " + cursor); + + console.log("Update vers to " + version); + vers = version; + + if (awaitingAck.current) return; + + textRef.current = text; + prevTextRef.current = text; + setText(text); + if (cursor && cursor > -1) { + console.log("Update cursor to " + cursor); + cursorRef.current = cursor; + setCursor(cursor); + } else { + cursorRef.current = prevCursorRef.current; + cursor = prevCursorRef.current; + console.log("Update cursor to " + prevCursorRef.current); + setCursor(prevCursorRef.current); } - ); + awaitingSync.current = false; + } + ); - return () => { - socketConnection.disconnect(); - if (room) { - room.disconnect(); - } + return () => { + socketConnection.disconnect(); + if (room) { + room.disconnect(); } - } - ); + }; + }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [roomId, userId]); + }, [roomId, userId, questionId]); useEffect(() => { textRef.current = text; @@ -167,7 +174,7 @@ const useCollaboration = ({ roomId, userId, disableVideo }: UseCollaborationProp }); }, [text, socket]); - return { text, setText, cursor, setCursor, room }; + return { text, setText, cursor, setCursor, room, setQuestionId }; }; export default useCollaboration; diff --git a/frontend/src/hooks/useMatch.tsx b/frontend/src/hooks/useMatch.tsx new file mode 100644 index 00000000..d62b88db --- /dev/null +++ b/frontend/src/hooks/useMatch.tsx @@ -0,0 +1,29 @@ +import { AuthContext } from "@/contexts/AuthContext"; +import { + getMatchByRoomid as getMatchByRoomidApi, + patchMatchQuestionByRoomid as patchMatchQuestionByRoomidApi, +} from "@/pages/api/matchHandler"; +import { User } from "firebase/auth"; +import { useContext } from "react"; + +export const useMatch = () => { + const { user: currentUser, authIsReady } = useContext(AuthContext); + + const getMatch = async (roomId: string) => { + if (authIsReady && currentUser) { + const match = await getMatchByRoomidApi(currentUser, roomId); + return match; + } + }; + + const updateQuestionIdInMatch = async ( + roomId: string, + questionId: string + ) => { + if (authIsReady && currentUser) { + await patchMatchQuestionByRoomidApi(currentUser, roomId, questionId); + } + }; + + return { getMatch, updateQuestionIdInMatch }; +}; diff --git a/frontend/src/hooks/useQuestions.ts b/frontend/src/hooks/useQuestions.ts index 66f4ca13..e4ad2959 100644 --- a/frontend/src/hooks/useQuestions.ts +++ b/frontend/src/hooks/useQuestions.ts @@ -3,6 +3,7 @@ import { fetchQuestions as fetchQuestionsApi, fetchRandomQuestion as fetchRandomQuestionApi, postQuestion as postNewQuestionApi, + fetchQuestion as fetchQuestionApi, } from "./../pages/api/questionHandler"; import { AuthContext } from "@/contexts/AuthContext"; import { Difficulty } from "../types/QuestionTypes"; @@ -10,6 +11,12 @@ import { Difficulty } from "../types/QuestionTypes"; export const useQuestions = () => { const { user: currentUser, authIsReady } = useContext(AuthContext); + const fetchQuestion = async (qid: string) => { + if (authIsReady && currentUser) { + return fetchQuestionApi(currentUser, qid); + } + }; + const fetchQuestions = async () => { if (authIsReady) { return fetchQuestionsApi(currentUser); @@ -31,5 +38,10 @@ export const useQuestions = () => { } }; - return { fetchQuestions, fetchRandomQuestion, postNewQuestion }; + return { + fetchQuestion, + fetchQuestions, + fetchRandomQuestion, + postNewQuestion, + }; }; diff --git a/frontend/src/pages/api/matchHandler.ts b/frontend/src/pages/api/matchHandler.ts new file mode 100644 index 00000000..5d124e9a --- /dev/null +++ b/frontend/src/pages/api/matchHandler.ts @@ -0,0 +1,69 @@ +import { matchApiPathAddress } from "@/gateway-address/gateway-address"; +import { Match } from "@/types/MatchTypes"; + +export const getMatchByRoomid = async (user: any, roomId: string) => { + try { + const url = `${matchApiPathAddress}match/${roomId}`; + const idToken = await user.getIdToken(true); + + const response = await fetch(url, { + method: "GET", + mode: "cors", + headers: { + "Content-Type": "application/json", + "User-Id-Token": idToken, + }, + }); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + if (!data) { + throw new Error("There was an error fetching the match"); + } else if (data.error) { + throw new Error(data.error); + } else if (!data.info) { + throw new Error("There was an error fetching the match"); + } + return { + roomId: data.info.roomId, + userId1: data.info.userId1, + userId2: data.info.userId2, + chosenDifficulty: data.info.chosenDifficulty, + chosenProgrammingLanguage: data.info.chosenProgrammingLanguage, + questionId: data.info.questionId, + createdAt: data.info.createdAt, + }; + } catch (error) { + console.error("There was an error fetching the match", error); + } +}; + +export const patchMatchQuestionByRoomid = async ( + user: any, + roomId: string, + questionId: string +) => { + try { + const url = `${matchApiPathAddress}match/${roomId}`; + const idToken = await user.getIdToken(true); + + const response = await fetch(url, { + method: "PATCH", + mode: "cors", + headers: { + "Content-Type": "application/json", + "User-Id-Token": idToken, + }, + body: JSON.stringify({ questionId: questionId }), + }); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + } catch (error) { + console.error("There was an error fetching the match", error); + } +}; diff --git a/frontend/src/pages/interviews/match-found.tsx b/frontend/src/pages/interviews/match-found.tsx index 0f1b7614..0b0f3228 100644 --- a/frontend/src/pages/interviews/match-found.tsx +++ b/frontend/src/pages/interviews/match-found.tsx @@ -7,6 +7,7 @@ import { TypographyH3, } from "@/components/ui/typography"; import { useMatchmaking } from "@/hooks/useMatchmaking"; +import { query } from "express"; import Link from "next/link"; import { useRouter } from "next/router"; diff --git a/frontend/src/pages/room/[id].tsx b/frontend/src/pages/room/[id].tsx index c6e56233..aeba200e 100644 --- a/frontend/src/pages/room/[id].tsx +++ b/frontend/src/pages/room/[id].tsx @@ -5,63 +5,168 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TypographyBody } from "@/components/ui/typography"; import { useRouter } from "next/router"; import VideoRoom from "../../components/room/video-room"; -import { Question } from "../../types/QuestionTypes"; +import { Difficulty, Question } from "../../types/QuestionTypes"; +import { Match } from "../../types/MatchTypes"; +import { useQuestions } from "@/hooks/useQuestions"; +import { useMatch } from "@/hooks/useMatch"; +import { useEffect, useState } from "react"; +import { MrMiyagi } from "@uiball/loaders"; export default function Room() { const router = useRouter(); - const roomId = router.query.id as string; - const userId = router.query.userId as string || "user1"; - const disableVideo = (router.query.disableVideo as string)?.toLowerCase() === "true"; + const userId = (router.query.userId as string) || "user1"; + const disableVideo = + (router.query.disableVideo as string)?.toLowerCase() === "true"; + + const { text, setText, cursor, setCursor, room, setQuestionId } = + useCollaboration({ + roomId: roomId as string, + userId, + disableVideo, + }); - const { text, setText, cursor, setCursor, room } = useCollaboration({ - roomId: roomId as string, - userId, - disableVideo, - }); + const [question, setQuestion] = useState(null); + const [loading, setLoading] = useState(true); // to be used later for loading states - const question: Question = { - title: "Two Sum", + const defaultQuestion: Question = { + title: "Example Question: Two Sum", difficulty: "Easy", topics: ["Array", "Hash Table"], - description: "Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.\n\nYou may assume that each input would have exactly one solution, and you may not use the same element twice.\n\nYou can return the answer in any order.", - solution: "var twoSum = function(nums, target) {\n for (let i = 0; i < nums.length; i++) {\n for (let j = i + 1; j < nums.length; j++) {\n if (nums[i] + nums[j] === target) {\n return [i, j];\n }\n }\n }\n};", + description: + "Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.\n\nYou may assume that each input would have exactly one solution, and you may not use the same element twice.\n\nYou can return the answer in any order.", + solution: + "var twoSum = function(nums, target) {\n for (let i = 0; i < nums.length; i++) {\n for (let j = i + 1; j < nums.length; j++) {\n if (nums[i] + nums[j] === target) {\n return [i, j];\n }\n }\n }\n};", defaultCode: { python: "var twoSum = function(nums, target) {\n\n};" }, id: "", - author: "" + author: "", }; - if (!router.isReady) return null; + const { fetchQuestion, fetchRandomQuestion } = useQuestions(); + const { getMatch, updateQuestionIdInMatch } = useMatch(); + const [match, setMatch] = useState(null); + + useEffect(() => { + getMatch(roomId) + .then((match) => { + if (match && match.questionId != null) { + setMatch(match); + const questionId = match.questionId; + fetchQuestion(questionId).then((fetchQuestion) => { + if (fetchQuestion != null) { + setQuestion(fetchQuestion); + setQuestionId(fetchQuestion.id); + console.log(questionId); + } + }); + } + }) + .catch((err) => { + console.log(err); + router.push("/"); + }) + .finally(() => { + setLoading(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [roomId]); + + function handleSwapQuestionClick(): void { + if (match) { + setLoading(true); + const difficulty = (match.chosenDifficulty || "easy") as Difficulty; + fetchRandomQuestion(difficulty) + .then((question) => { + if (question) { + updateQuestionIdInMatch(roomId, question.id); + setQuestion(question); + setQuestionId(question.id); + console.log(question.id); + } + }) + .catch((err) => { + console.log(err); + router.push("/"); + }) + .finally(() => { + setLoading(false); + }); + } + } return ( -
-
- - - - Description - - - Solution - - - - - - {question.solution} - -
- +
+ {!router.isReady ? ( +
+ +
+ ) : ( +
+
+
+ + + + Description + + + Solution + + + + {loading ? ( +
+ +
+ ) : question != null ? ( + + ) : ( + + )} +
+ {loading ? ( +
+ +
+ ) : question != null && "solution" in question ? ( + + {question.solution} + + ) : ( + + {defaultQuestion.solution} + + )} +
+
+ +
+
+ +
-
- + )}
); } diff --git a/frontend/src/providers/MatchmakingProvider.tsx b/frontend/src/providers/MatchmakingProvider.tsx index aa5818a8..b58ccef9 100644 --- a/frontend/src/providers/MatchmakingProvider.tsx +++ b/frontend/src/providers/MatchmakingProvider.tsx @@ -8,7 +8,7 @@ import React, { import { io, Socket } from "socket.io-client"; import { Match } from "@prisma/client"; import { AuthContext } from "@/contexts/AuthContext"; -import {wsMatchProxyGatewayAddress} from "@/gateway-address/gateway-address"; +import { wsMatchProxyGatewayAddress } from "@/gateway-address/gateway-address"; const SERVER_URL = wsMatchProxyGatewayAddress; @@ -49,26 +49,24 @@ export const MatchmakingProvider: React.FC = ({ // Initialize socket connection useEffect(() => { if (currentUser) { - currentUser.getIdToken(true).then( - (token) => { - const newSocket = io(SERVER_URL, { - autoConnect: false, - // query: { username: currentUser?.email }, - query: { username: generateRandomNumber() }, - extraHeaders: { - "User-Id-Token": token - } - }); - setSocket(newSocket); - newSocket.connect(); - - console.log("Socket connected"); - - return () => { - newSocket.close(); - }; - } - ) + currentUser.getIdToken(true).then((token) => { + const newSocket = io(SERVER_URL, { + autoConnect: false, + query: { username: currentUser?.uid }, + //query: { username: generateRandomNumber() }, + extraHeaders: { + "User-Id-Token": token, + }, + }); + setSocket(newSocket); + newSocket.connect(); + + console.log("Socket connected"); + + return () => { + newSocket.close(); + }; + }); } }, [currentUser]); @@ -81,6 +79,7 @@ export const MatchmakingProvider: React.FC = ({ socket.on("matchFound", (match: Match) => { console.log("Match found:", match); + console.log("QuestionId:", match.questionId); setMatch(match); }); diff --git a/frontend/src/types/MatchTypes.ts b/frontend/src/types/MatchTypes.ts new file mode 100644 index 00000000..7837a36d --- /dev/null +++ b/frontend/src/types/MatchTypes.ts @@ -0,0 +1,9 @@ +export type Match = { + roomId: string; + userId1: string; + userId2: string; + chosenDifficulty: string; + chosenProgrammingLanguage: string; + questionId?: string | null; + createdAt: Date; +}; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 35776b59..9de8f6cb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,7 +10,6 @@ datasource db { url = env("PRISMA_DATABASE_URL") } -// todo rename for colalboration service model User { id String @id @default(uuid()) isLookingForMatch Boolean @@ -24,6 +23,7 @@ model Match { userId2 String chosenDifficulty String chosenProgrammingLanguage String + questionId String? createdAt DateTime @default(now()) } diff --git a/services/admin-service/package.json b/services/admin-service/package.json index 6362a89d..4195d8ff 100644 --- a/services/admin-service/package.json +++ b/services/admin-service/package.json @@ -4,13 +4,13 @@ "private": true, "scripts": { "lint": "eslint src/**/*.{ts,js} test/**/*.{ts,js} systemtest/**/*.{ts,js} swagger-doc-gen.ts", - "dev:local": "dotenv -e ../../.env -c development yarn dev", + "dev:local": "dotenv -e ../../.env -c development yarnpkg dev", "dev": "ts-node-dev src/app.ts", - "build": "yarn swagger-autogen && tsc", + "build": "yarnpkg swagger-autogen && tsc", "start": "node dist/src/app.js", - "test": "dotenv -e ../../.env.firebase_emulators_test yarn test:ci", + "test": "dotenv -e ../../.env.firebase_emulators_test yarnpkg test:ci", "test:ci": "firebase emulators:exec \"vitest run -c ./test/vitest.config.unit.ts\"", - "systemtest": "dotenv -e ../../.env.firebase_emulators_test yarn systemtest:ci", + "systemtest": "dotenv -e ../../.env.firebase_emulators_test yarnpkg systemtest:ci", "systemtest:ci": "firebase emulators:exec \"vitest run -c ./systemtest/vitest.config.system.ts\"", "swagger-autogen": "ts-node-dev swagger-doc-gen.ts" }, diff --git a/services/collaboration-service/package.json b/services/collaboration-service/package.json index 9d794a3d..1e8a801d 100644 --- a/services/collaboration-service/package.json +++ b/services/collaboration-service/package.json @@ -5,9 +5,10 @@ "main": "src/app.ts", "scripts": { "lint": "eslint src/**/*.{ts,js} swagger-doc-gen.ts", - "build": "yarn run swagger-autogen && tsc", + "build": "yarnpkg run swagger-autogen && tsc", "start": "node ./dist/src/app.js", "dev": "ts-node-dev src/app.ts", + "dev:local": "dotenv -e ../../.env -c development -- yarnpkg dev", "swagger-autogen": "ts-node swagger-doc-gen.ts" }, "dependencies": { diff --git a/services/collaboration-service/src/db/prisma-db.ts b/services/collaboration-service/src/db/prisma-db.ts index bd4d8375..c5b8b059 100644 --- a/services/collaboration-service/src/db/prisma-db.ts +++ b/services/collaboration-service/src/db/prisma-db.ts @@ -195,6 +195,7 @@ export async function updateRoomText( room_id: string, text: string ): Promise { + if (room_id == null) return; await prisma.room.update({ where: { room_id: room_id, diff --git a/services/gateway/package.json b/services/gateway/package.json index 9f079be6..1e7a5183 100644 --- a/services/gateway/package.json +++ b/services/gateway/package.json @@ -6,7 +6,7 @@ "main": "src/app.ts", "scripts": { "lint": "eslint src/**/*.{ts,js}", - "dev:local": "dotenv -e ../../.env -c development -- yarn dev", + "dev:local": "dotenv -e ../../.env -c development -- yarnpkg dev", "dev": "ts-node-dev src/app.ts", "build": "tsc", "start": "node dist/src/app.js", diff --git a/services/gateway/src/app.ts b/services/gateway/src/app.ts index 49e80fcd..d9967b3c 100644 --- a/services/gateway/src/app.ts +++ b/services/gateway/src/app.ts @@ -1,33 +1,40 @@ -import express, {Express} from 'express'; -import cors from 'cors'; +import express, { Express } from "express"; +import cors from "cors"; import { setupLogging } from "./logging/logging"; import { setupAdmin, setupUserIdMatch, setupIsLoggedIn } from "./auth/auth"; import { setupProxies } from "./proxy/proxy"; -import {http_proxied_routes, wsCollaborationProxiedRoutes, wsMatchProxiedRoutes} from "./proxied_routes/proxied_routes"; -import {frontendAddress} from "./proxied_routes/service_names"; -import {createProxyMiddleware} from "http-proxy-middleware"; +import { + http_proxied_routes, + wsCollaborationProxiedRoutes, + wsMatchProxiedRoutes, +} from "./proxied_routes/proxied_routes"; +import { frontendAddress } from "./proxied_routes/service_names"; +import { createProxyMiddleware } from "http-proxy-middleware"; import healthCheck from "express-healthcheck"; - -const httpApp : Express = express(); -const wsMatchApp : Express = express(); -const wsCollaborationApp : Express = express(); +const httpApp: Express = express(); +const wsMatchApp: Express = express(); +const wsCollaborationApp: Express = express(); const corsOptions = { origin: frontendAddress, - methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"] -} + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], +}; -const httpProxyPort : number = parseInt(process.env.HTTP_PROXY_PORT || "4000"); -const wsMatchProxyPort : number = parseInt(process.env.WS_MATCH_PROXY_PORT || "4002"); -const wsCollaborationProxyPort : number = parseInt(process.env.WS_COLLABORATION_PROXY_PORT || "4003"); +const httpProxyPort: number = parseInt(process.env.HTTP_PROXY_PORT || "4000"); +const wsMatchProxyPort: number = parseInt( + process.env.WS_MATCH_PROXY_PORT || "4002" +); +const wsCollaborationProxyPort: number = parseInt( + process.env.WS_COLLABORATION_PROXY_PORT || "4003" +); httpApp.use(cors(corsOptions)); wsMatchApp.use(cors(corsOptions)); wsCollaborationApp.use(cors(corsOptions)); // Health check -httpApp.use('/healthcheck', healthCheck()); +httpApp.use("/healthcheck", healthCheck()); /** * WARNING: Do not add body parsing middleware to the Gateway. @@ -41,24 +48,34 @@ setupIsLoggedIn(httpApp, http_proxied_routes); setupIsLoggedIn(wsMatchApp, wsMatchProxiedRoutes); setupIsLoggedIn(wsCollaborationApp, wsCollaborationProxiedRoutes); - setupUserIdMatch(httpApp, http_proxied_routes); setupAdmin(httpApp, http_proxied_routes); setupProxies(httpApp, http_proxied_routes); -const wsMatchProxyMiddleware = createProxyMiddleware(wsMatchProxiedRoutes[0].proxy); +const wsMatchProxyMiddleware = createProxyMiddleware( + wsMatchProxiedRoutes[0].proxy +); wsMatchApp.use(wsMatchProxiedRoutes[0].url, wsMatchProxyMiddleware); -const wsCollaborationProxyMiddleware = createProxyMiddleware(wsCollaborationProxiedRoutes[0].proxy); -wsCollaborationApp.use(wsCollaborationProxiedRoutes[0].url, wsCollaborationProxyMiddleware); +const wsCollaborationProxyMiddleware = createProxyMiddleware( + wsCollaborationProxiedRoutes[0].proxy +); +wsCollaborationApp.use( + wsCollaborationProxiedRoutes[0].url, + wsCollaborationProxyMiddleware +); httpApp.listen(httpProxyPort, () => { console.log(`Gateway HTTP proxy listening on port ${httpProxyPort}`); -}) +}); wsMatchApp.listen(wsMatchProxyPort, () => { - console.log(`Gateway WebSockets Match Proxy listening on port ${wsMatchProxyPort}`); -}) + console.log( + `Gateway WebSockets Match Proxy listening on port ${wsMatchProxyPort}` + ); +}); wsCollaborationApp.listen(wsCollaborationProxyPort, () => { - console.log(`Gateway WebSockets Collaboration Proxy listening on port ${wsCollaborationProxyPort}`); -}) + console.log( + `Gateway WebSockets Collaboration Proxy listening on port ${wsCollaborationProxyPort}` + ); +}); diff --git a/services/matching-service/package.json b/services/matching-service/package.json index e49d5e48..aaa598d4 100644 --- a/services/matching-service/package.json +++ b/services/matching-service/package.json @@ -4,9 +4,9 @@ "private": true, "scripts": { "lint": "eslint src/**/*.{ts,js} swagger-doc-gen.ts", - "build": "yarn run swagger-autogen && tsc", + "build": "yarnpkg run swagger-autogen && tsc", "start": "node ./dist/src/app.js", - "dev:local": "dotenv -e ../../.env -c development -- yarn dev", + "dev:local": "dotenv -e ../../.env -c development -- yarnpkg dev", "dev": "ts-node-dev src/app.ts", "swagger-autogen": "ts-node swagger-doc-gen.ts" }, diff --git a/services/matching-service/src/app.ts b/services/matching-service/src/app.ts index 1d574daa..2b86c3bc 100644 --- a/services/matching-service/src/app.ts +++ b/services/matching-service/src/app.ts @@ -26,7 +26,7 @@ app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerFile)); const socketIoOptions: any = { cors: { origin: "http://localhost:3000", - methods: ["GET", "POST"], + methods: ["GET", "POST", "PATCH"], }, }; diff --git a/services/matching-service/src/controllers/matchingController.ts b/services/matching-service/src/controllers/matchingController.ts index 06dbe86d..0f39b063 100644 --- a/services/matching-service/src/controllers/matchingController.ts +++ b/services/matching-service/src/controllers/matchingController.ts @@ -4,6 +4,7 @@ import { io } from "../app"; import prisma from "../prismaClient"; import EventEmitter from "events"; import { Match } from "@prisma/client"; +import { getRandomQuestionOfDifficulty } from "../questionAdapter"; export const MAX_WAITING_TIME = 60 * 1000; // 60 seconds @@ -192,6 +193,12 @@ export function handleLooking( `Match found for user ${userId} with user ${matchId} and difficulty ${difficulty}` ); + const questionId = await getRandomQuestionOfDifficulty( + difficulty! ?? "easy" + ).then((questionId) => { + return questionId; + }); + // Inform both users of the match const newMatch = await prisma .$transaction([ @@ -201,16 +208,17 @@ export function handleLooking( userId2: matchId, chosenDifficulty: difficulty || "easy", chosenProgrammingLanguage: programmingLang, + questionId: questionId, }, }), - prisma.user.update({ - where: { id: userId }, - data: { matchedUserId: matchId }, - }), - prisma.user.update({ - where: { id: matchId }, - data: { matchedUserId: userId }, - }), + // prisma.user.update({ + // where: { id: userId }, + // data: { matchedUserId: matchId }, + // }), + // prisma.user.update({ + // where: { id: matchId }, + // data: { matchedUserId: userId }, + // }), ]) .catch((err) => { console.log(err); @@ -487,11 +495,21 @@ export const findMatch = async (req: Request, res: Response) => { }, }); + // This function and REST API seems to be not in use + const questionId = await getRandomQuestionOfDifficulty( + difficulties[0] + ).then( + // difficulties???? need to intersect difficulties or not + (questionId) => { + return questionId; + } + ); + // Emit match found event to both users - io.to(userId.toString()).emit("matchFound", match); - io.to(match.id.toString()).emit("matchFound", user); + io.to(userId.toString()).emit("matchFound", { match, questionId }); + io.to(match.id.toString()).emit("matchFound", { user, questionId }); - return res.json({ match }); + return res.json({ match, questionId }); } // If no immediate match is found, keep the user in the queue @@ -525,3 +543,54 @@ export const leaveMatch = async (req: Request, res: Response) => { res.status(200).json({ message: "Successfully left the match" }); }; + +export async function getMatch(req: Request, res: Response) { + const room_id = req.params.room_id as string; + + const match = await prisma.match.findUnique({ where: { roomId: room_id } }); + + if (!match) { + return res.status(404).json({ error: "Match not found" }); + } + + return res.status(200).json({ + message: "Match exists", + room_id: room_id, + info: match, + }); +} + +export async function updateMatchQuestion(req: Request, res: Response) { + const room_id = req.params.room_id as string; + + const { questionId } = req.body; + + if (!questionId) { + return res + .status(400) + .json({ error: "Invalid or missing questionId in the request body" }); + } + + const match = await prisma.match.findUnique({ where: { roomId: room_id } }); + + if (!match) { + return res.status(404).json({ error: "Match not found" }); + } + + try { + const updatedMatch = await prisma.match.update({ + where: { roomId: room_id }, + data: { + questionId, + }, + }); + + return res.status(200).json({ + message: "Match updated successfully", + room_id: room_id, + info: updatedMatch, + }); + } catch (error) { + return res.status(500).json({ error: "Failed to update the match" }); + } +} diff --git a/services/matching-service/src/questionAdapter.ts b/services/matching-service/src/questionAdapter.ts new file mode 100644 index 00000000..226672a6 --- /dev/null +++ b/services/matching-service/src/questionAdapter.ts @@ -0,0 +1,50 @@ +import http from "http"; + +export async function getRandomQuestionOfDifficulty( + difficulty: string +): Promise { + const requestBody = JSON.stringify({ difficulty }); + + const options = { + hostname: "localhost", + port: 5004, // Port of the question service + path: "/api/question-service/random-question", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(requestBody), + }, + }; + + return new Promise((resolve, reject) => { + const req = http.request(options, (response) => { + let data = ""; + + response.on("data", (chunk) => { + data += chunk; + }); + + response.on("end", () => { + try { + const parsedData = JSON.parse(data); + console.log(parsedData); + const qnId = parsedData[0]._id; + if (qnId) { + resolve(qnId); + } else { + reject(new Error("Invalid response format")); + } + } catch (err) { + reject(err); + } + }); + }); + + req.on("error", (err) => { + reject(err); + }); + + req.write(requestBody); + req.end(); + }); +} diff --git a/services/matching-service/src/routes/matchingRoutes.ts b/services/matching-service/src/routes/matchingRoutes.ts index 411bb603..bda0ebcc 100644 --- a/services/matching-service/src/routes/matchingRoutes.ts +++ b/services/matching-service/src/routes/matchingRoutes.ts @@ -1,10 +1,17 @@ import express from "express"; -import * as matchingController from "../controllers/matchingController"; +import { + findMatch, + getMatch, + leaveMatch, + updateMatchQuestion, +} from "../controllers/matchingController"; const router = express.Router(); -router.get("/:userId/findMatch", matchingController.findMatch); -router.post("/:userId/leave", matchingController.leaveMatch); -router.get("/", (req, res) => res.sendFile(__dirname + "/index.html")) +router.get("/:userId/findMatch", findMatch); +router.post("/:userId/leave", leaveMatch); +router.get("/match/:room_id", getMatch); +router.patch("/match/:room_id", updateMatchQuestion); +router.get("/demo", (req, res) => res.sendFile(__dirname + "/index.html")); export default router; diff --git a/services/matching-service/src/swagger-output.json b/services/matching-service/src/swagger-output.json index 9fbca0b2..6e329ba1 100644 --- a/services/matching-service/src/swagger-output.json +++ b/services/matching-service/src/swagger-output.json @@ -1,13 +1,13 @@ { "openapi": "3.0.0", "info": { - "title": "Collaboration Service", - "description": "Provides the mechanism for real-time collaboration (e.g., concurrent code editing) between the authenticated and matched users in the collaborative space", + "title": "Matching Service", + "description": "", "version": "1.0.0" }, "servers": [ { - "url": "http://localhost:5001/" + "url": "http://localhost:5002/" } ], "paths": { diff --git a/services/matching-service/swagger-doc-gen.ts b/services/matching-service/swagger-doc-gen.ts index 6be1eff9..e9b0f20c 100644 --- a/services/matching-service/swagger-doc-gen.ts +++ b/services/matching-service/swagger-doc-gen.ts @@ -2,11 +2,10 @@ import swaggerAutogen from "swagger-autogen"; const doc = { info: { - title: "Collaboration Service", - description: - "Provides the mechanism for real-time collaboration (e.g., concurrent code editing) between the authenticated and matched users in the collaborative space", + title: "Matching Service", + description: "", }, - host: "localhost:5001", + host: "localhost:5002", schemes: ["http"], }; diff --git a/services/question-service/package.json b/services/question-service/package.json index dd965c13..4779901c 100644 --- a/services/question-service/package.json +++ b/services/question-service/package.json @@ -4,9 +4,9 @@ "private": true, "scripts": { "lint": "eslint src/**/*.{ts,js} swagger-doc-gen.ts", - "build": "yarn run swagger-autogen && tsc", + "build": "yarnpkg run swagger-autogen && tsc", "start": "node ./dist/src/app.js", - "dev:local": "dotenv -e ../../.env -c development -- yarn dev", + "dev:local": "dotenv -e ../../.env -c development -- yarnpkg dev", "dev": "ts-node-dev src/app.ts", "swagger-autogen": "ts-node swagger-doc-gen.ts" }, diff --git a/services/question-service/src/swagger-output.json b/services/question-service/src/swagger-output.json index 9e5042b8..b7cf9d08 100644 --- a/services/question-service/src/swagger-output.json +++ b/services/question-service/src/swagger-output.json @@ -7,7 +7,7 @@ }, "servers": [ { - "url": "http://localhost:5002/" + "url": "http://localhost:5004/" } ], "paths": { diff --git a/services/question-service/swagger-doc-gen.ts b/services/question-service/swagger-doc-gen.ts index 627d8c78..22ab1549 100644 --- a/services/question-service/swagger-doc-gen.ts +++ b/services/question-service/swagger-doc-gen.ts @@ -1,19 +1,19 @@ -import swaggerAutogen from 'swagger-autogen'; +import swaggerAutogen from "swagger-autogen"; const doc = { info: { - title: 'Question Service API', - description: 'API for CRUD operations on questions', + title: "Question Service API", + description: "API for CRUD operations on questions", }, - host: 'localhost:5002', - schemes: ['http'], + host: "localhost:5004", + schemes: ["http"], }; -const outputFile = './src/swagger-output.json'; -const endpointsFiles = ['./src/app.ts']; +const outputFile = "./src/swagger-output.json"; +const endpointsFiles = ["./src/app.ts"]; /* NOTE: if you use the express Router, you must pass in the 'endpointsFiles' only the root file where the route starts, such as index.ts, app.ts, routes.js, ... */ -swaggerAutogen({openapi: '3.1.0'})(outputFile, endpointsFiles, doc); +swaggerAutogen({ openapi: "3.1.0" })(outputFile, endpointsFiles, doc); diff --git a/services/user-service/package.json b/services/user-service/package.json index c61c224c..f7edb96f 100644 --- a/services/user-service/package.json +++ b/services/user-service/package.json @@ -3,17 +3,17 @@ "version": "0.0.0", "private": true, "scripts": { - "dev:local": "dotenv -e ../../.env -c development -- yarn dev", + "dev:local": "dotenv -e ../../.env -c development -- yarnpkg dev", "dev": "ts-node-dev src/app.ts", "swagger-autogen": "ts-node swagger-doc-gen.ts", "lint": "eslint src/**/*.{ts,js} test/**/*.{ts,js} systemtest/**/*.{ts,js} swagger-doc-gen.ts", - "build": "yarn swagger-autogen && tsc", + "build": "yarnpkg swagger-autogen && tsc", "start": "node dist/src/app.js", "test": "vitest run -c ./test/vitest.config.unit.ts", - "systemtest": "dotenv -e systemtest/secrets/.env.user-service-system-test yarn systemtest:ci", - "systemtest:ci": "yarn systemtest:docker:up && yarn systemtest:prisma:migrate:deploy && yarn systemtest:vitest && yarn systemtest:docker:down", + "systemtest": "dotenv -e systemtest/secrets/.env.user-service-system-test yarnpkg systemtest:ci", + "systemtest:ci": "yarnpkg systemtest:docker:up && yarnpkg systemtest:prisma:migrate:deploy && yarnpkg systemtest:vitest && yarnpkg systemtest:docker:down", "systemtest:docker:up": "docker compose -f ./systemtest/user-service-postgre-Docker-compose.yml up -d", - "systemtest:prisma:migrate:deploy": "yarn prisma migrate deploy && prisma generate", + "systemtest:prisma:migrate:deploy": "yarnpkg prisma migrate deploy && prisma generate", "systemtest:vitest": "vitest run -c ./systemtest/vitest.config.system.ts", "systemtest:docker:down": "docker compose -f ./systemtest/user-service-postgre-Docker-compose.yml down" }, diff --git a/start-app-no-docker.sh b/start-app-no-docker.sh index 487f6fa2..85342f8f 100755 --- a/start-app-no-docker.sh +++ b/start-app-no-docker.sh @@ -6,13 +6,13 @@ prepend() { done } -(yarn install --frozen-lockfile && yarn prisma generate && \ +(yarnpkg install --frozen-lockfile && yarnpkg prisma generate && \ trap 'kill 0' INT TERM; \ - (yarn workspace frontend dev:local | prepend "frontend: ") & \ - (yarn workspace user-service dev:local | prepend "user-service: ") & \ - (yarn workspace admin-service dev:local | prepend "admin-service: ") & \ - (yarn workspace collaboration-service dev | prepend "collaboration-service: ") & \ - (yarn workspace matching-service dev:local | prepend "matching-service: ") & \ - (yarn workspace question-service dev:local | prepend "question-service: ") & \ - (yarn workspace gateway dev:local | prepend "gateway: ") & \ + (yarnpkg workspace frontend dev:local | prepend "frontend: ") & \ + (yarnpkg workspace user-service dev:local | prepend "user-service: ") & \ + (yarnpkg workspace admin-service dev:local | prepend "admin-service: ") & \ + (yarnpkg workspace collaboration-service dev:local | prepend "collaboration-service: ") & \ + (yarnpkg workspace matching-service dev:local | prepend "matching-service: ") & \ + (yarnpkg workspace question-service dev:local | prepend "question-service: ") & \ + (yarnpkg workspace gateway dev:local | prepend "gateway: ") & \ wait) diff --git a/start-app-with-docker.sh b/start-app-with-docker.sh index 268f0de2..4accbe24 100644 --- a/start-app-with-docker.sh +++ b/start-app-with-docker.sh @@ -3,7 +3,7 @@ # These are the steps needed for docker to function # Step 1: Build the root-level Dockerfile and docker-compose services -yarn docker:build +yarnpkg docker:build # Step 2: Run the entire application -yarn docker:devup +yarnpkg docker:devup From e78450b18fef493f25f19c0b94a5a29effe99e19 Mon Sep 17 00:00:00 2001 From: Lee Chun Wei <47494777+chunweii@users.noreply.github.com> Date: Thu, 2 Nov 2023 00:53:58 +0800 Subject: [PATCH 29/50] Link user profile settings to backend (firebase and supabase) (#210) Fixes #189 and fixes #124 --- frontend/src/components/profile/columns.tsx | 2 +- .../src/firebase-client/useUpdateProfile.ts | 15 +++ frontend/src/hooks/useHistory.tsx | 11 +- frontend/src/hooks/useUser.ts | 10 +- frontend/src/pages/api/historyHandler.ts | 42 +++++- frontend/src/pages/api/userHandler.ts | 27 ++++ frontend/src/pages/attempt/[id]/index.tsx | 60 ++++++++- frontend/src/pages/profile/[id]/index.tsx | 43 ++++-- frontend/src/pages/profile/_profile.tsx | 122 +++++++++-------- frontend/src/pages/profile/index.tsx | 125 +++--------------- frontend/src/pages/questions/new.tsx | 1 + frontend/src/pages/settings/_account.tsx | 28 +++- frontend/src/pages/settings/_match.tsx | 68 ++++++++-- frontend/src/types/UserTypes.ts | 2 +- .../migration.sql | 54 ++++++++ prisma/schema.prisma | 31 ++--- services/admin-service/.gitignore | 64 +++++++++ services/gateway/.gitignore | 64 +++++++++ .../matching-service/src/swagger-output.json | 66 ++++++++- services/user-service/src/db/functions.ts | 35 +++++ services/user-service/src/routes/index.ts | 20 +++ services/user-service/src/swagger-output.json | 23 ++++ services/user-service/systemtest/app.test.ts | 10 +- .../user-service/test/db/functions.test.ts | 4 +- .../user-service/test/routes/index.test.ts | 4 +- 25 files changed, 709 insertions(+), 222 deletions(-) create mode 100644 frontend/src/firebase-client/useUpdateProfile.ts create mode 100644 prisma/migrations/20231031082108_change_match_difficulty_to_string/migration.sql create mode 100644 services/admin-service/.gitignore create mode 100644 services/gateway/.gitignore diff --git a/frontend/src/components/profile/columns.tsx b/frontend/src/components/profile/columns.tsx index 38cfb5ef..2f0cea8f 100644 --- a/frontend/src/components/profile/columns.tsx +++ b/frontend/src/components/profile/columns.tsx @@ -31,7 +31,7 @@ export const columns: ColumnDef[] = [ id: "actions", header: "Actions", cell: ({ row }) => { - const attemptId = row.id; + const attemptId = row.original.id; return (
}
) } diff --git a/frontend/src/pages/profile/[id]/index.tsx b/frontend/src/pages/profile/[id]/index.tsx index e3bdb22c..7f0bb304 100644 --- a/frontend/src/pages/profile/[id]/index.tsx +++ b/frontend/src/pages/profile/[id]/index.tsx @@ -1,17 +1,44 @@ import Profile from "../_profile"; +import { useContext, useEffect, useState } from "react"; +import { AuthContext } from "@/contexts/AuthContext"; +import { Attempt } from "@/types/UserTypes"; +import { useHistory } from "@/hooks/useHistory"; +import { useRouter } from "next/router"; import { User } from "firebase/auth"; +import { useUser } from "@/hooks/useUser"; export default function Page() { - // TODO: retrieve selected user from user id in url + const router = useRouter(); + const id = router.query.id; + const { getAppUser } = useUser(); + const { user: currentUser } = useContext(AuthContext); + const { fetchAttempts } = useHistory(); - const selectedUser = { - displayName: "John Doe", - email: "johndoe@email.com", - photoURL: "https://www.gravatar.com/avatar/00", - } + const [attempts, setAttempts] = useState([]); + const [user, setUser] = useState(); + const [loadingState, setLoadingState] = useState<"loading" | "error" | "success">("loading"); + + useEffect(() => { + if (currentUser && (typeof id === "string")) { + Promise.all([getAppUser(id), fetchAttempts(id)]).then(([user, attempts]) => { + if (user && attempts) { + user["photoURL"] = user["photoUrl"]; + console.log(user); + setUser(user); + setAttempts(attempts); + setLoadingState("success"); + } else { + throw new Error("User or attempts not found"); + } + }).catch((err: any) => { + setLoadingState("error"); + console.log(err); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser]); - // TODO: if selected user is null, redirect to 404 page return ( - + (user && ) ) } diff --git a/frontend/src/pages/profile/_profile.tsx b/frontend/src/pages/profile/_profile.tsx index 0d33ea5e..95be55ed 100644 --- a/frontend/src/pages/profile/_profile.tsx +++ b/frontend/src/pages/profile/_profile.tsx @@ -9,14 +9,16 @@ import { Tooltip as MuiTooltip } from '@mui/material'; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { DataTable } from "@/components/profile/data-table"; import { columns } from "@/components/profile/columns"; +import { DotWave } from "@uiball/loaders"; type ProfileProps = { selectedUser: User, + loadingState: "loading" | "error" | "success", attempts?: Attempt[], isCurrentUser: boolean, } -export default function Profile({ selectedUser, attempts, isCurrentUser }: ProfileProps) { +export default function Profile({ selectedUser, attempts, isCurrentUser, loadingState }: ProfileProps) { const getInitials = (name: string) => { const names = name.split(" "); let initials = ""; @@ -37,27 +39,30 @@ export default function Profile({ selectedUser, attempts, isCurrentUser }: Profi [dateLastYearString]: { date: dateLastYearString, count: 0, level: 0 }, }; - // Transform attempts into activities and accumulate counts - attempts?.forEach((attempt) => { - const date = attempt.time_created.toISOString().slice(0, 10); // Format the date as yyyy-MM-dd + if (loadingState === "success") { + // Transform attempts into activities and accumulate counts + attempts?.forEach((attempt) => { + const date = attempt.time_created.toISOString().slice(0, 10); // Format the date as yyyy-MM-dd - if (!countsByDate[date]) { - countsByDate[date] = { date, count: 0, level: 1 }; - } + if (!countsByDate[date]) { + countsByDate[date] = { date, count: 0, level: 1 }; + } - countsByDate[date].count += 1; + countsByDate[date].count += 1; + + // Set the level of the activity based on the number of activities on that day + if (countsByDate[date].count === 1) { + countsByDate[date].level = 1; + } else if (countsByDate[date].count > 1 && countsByDate[date].count < 5) { + countsByDate[date].level = 2; + } else if (countsByDate[date].count >= 5 && countsByDate[date].count < 10) { + countsByDate[date].level = 3; + } else if (countsByDate[date].count >= 10) { + countsByDate[date].level = 4; + } + }); + } - // Set the level of the activity based on the number of activities on that day - if (countsByDate[date].count === 1) { - countsByDate[date].level = 1; - } else if (countsByDate[date].count > 1 && countsByDate[date].count < 5) { - countsByDate[date].level = 2; - } else if (countsByDate[date].count >= 5 && countsByDate[date].count < 10) { - countsByDate[date].level = 3; - } else if (countsByDate[date].count >= 10) { - countsByDate[date].level = 4; - } - }); // Extract the values from the dictionary to get the final activities array const activities = Object.values(countsByDate).sort((a, b) => a.date.localeCompare(b.date)); @@ -84,41 +89,50 @@ export default function Profile({ selectedUser, attempts, isCurrentUser }: Profi }
- - - - Activity - - - - ( - - {block} - - )} - labels={{ - totalCount: '{{count}} activities in 2023', - }} - /> - - - - - - Attempts - - - - - - + + + + Activity + + + + ( + + {block} + + )} + labels={{ + totalCount: '{{count}} activities in 2023', + }} + /> + + + + + + Attempts + + + + {loadingState === "loading" ?
+ +
: loadingState === "error" ?
+ Something went wrong. Please try again later. +
: + } +
+
) diff --git a/frontend/src/pages/profile/index.tsx b/frontend/src/pages/profile/index.tsx index 6cadac8a..a8e16192 100644 --- a/frontend/src/pages/profile/index.tsx +++ b/frontend/src/pages/profile/index.tsx @@ -1,115 +1,32 @@ -import { useContext } from "react"; +import { useContext, useEffect, useState } from "react"; import { AuthContext } from "@/contexts/AuthContext"; import Profile from "./_profile"; import { Attempt } from "@/types/UserTypes"; +import { useHistory } from "@/hooks/useHistory"; export default function Page() { const { user: currentUser } = useContext(AuthContext); + const { fetchAttempts } = useHistory(); - const attempts: Attempt[] = [ - { - id: "1", - question_id: "1", - answer: "A1", - solved: true, - time_created: new Date("2023-10-13T08:00:00"), - time_saved_at: new Date("2023-10-13T08:15:00"), - time_updated: new Date("2023-10-13T08:15:00"), - room_id: "Room1", - }, - { - id: "2", - question_id: "2", - answer: "A2", - solved: true, - time_created: new Date("2023-10-23T08:30:00"), - time_saved_at: new Date("2023-10-23T08:45:00"), - time_updated: new Date("2023-10-23T08:45:00"), - room_id: "Room1", - }, - { - id: "3", - question_id: "3", - answer: "A3", - solved: true, - time_created: new Date("2023-10-23T09:00:00"), - time_saved_at: new Date("2023-10-23T09:15:00"), - time_updated: new Date("2023-10-23T09:15:00"), - room_id: "Room2", - }, - { - id: "4", - question_id: "4", - answer: "A4", - solved: false, - time_created: new Date("2023-10-23T09:30:00"), - time_saved_at: new Date("2023-10-23T09:45:00"), - time_updated: new Date("2023-10-23T09:45:00"), - room_id: "Room2", - }, - { - id: "5", - question_id: "5", - answer: "A5", - solved: false, - time_created: new Date("2023-10-23T10:00:00"), - time_saved_at: new Date("2023-10-23T10:15:00"), - time_updated: new Date("2023-10-23T10:15:00"), - room_id: null, - }, - { - id: "6", - question_id: "6", - answer: null, - solved: false, - time_created: new Date("2023-10-23T10:30:00"), - time_saved_at: new Date("2023-10-23T10:45:00"), - time_updated: new Date("2023-10-23T10:45:00"), - room_id: null, - }, - { - id: "7", - question_id: "7", - answer: "A7", - solved: true, - time_created: new Date("2023-10-23T11:00:00"), - time_saved_at: new Date("2023-10-23T11:15:00"), - time_updated: new Date("2023-10-23T11:15:00"), - room_id: "Room3", - }, - { - id: "8", - question_id: "8", - answer: "A8", - solved: true, - time_created: new Date("2023-10-23T11:30:00"), - time_saved_at: new Date("2023-10-23T11:45:00"), - time_updated: new Date("2023-10-23T11:45:00"), - room_id: "Room3", - }, - { - id: "9", - question_id: "9", - answer: "A9", - solved: true, - time_created: new Date("2023-10-03T12:00:00"), - time_saved_at: new Date("2023-10-03T12:15:00"), - time_updated: new Date("2023-10-03T12:15:00"), - room_id: "Room4", - }, - { - id: "10", - question_id: "10", - answer: "A10", - solved: false, - time_created: new Date("2023-10-23T12:30:00"), - time_saved_at: new Date("2023-10-23T12:45:00"), - time_updated: new Date("2023-10-23T12:45:00"), - room_id: "Room4", - }, - ]; + const [attempts, setAttempts] = useState([]); + const [loadingState, setLoadingState] = useState<"loading" | "error" | "success">("loading"); + + useEffect(() => { + if (currentUser) { + fetchAttempts(currentUser.uid).then((attempts) => { + if (attempts) { + setAttempts(attempts); + setLoadingState("success"); + } + }).catch((err: any) => { + setLoadingState("error"); + console.log(err); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser]); return ( - (currentUser && ) + (currentUser && ) ) } diff --git a/frontend/src/pages/questions/new.tsx b/frontend/src/pages/questions/new.tsx index 1a4094e1..af67c160 100644 --- a/frontend/src/pages/questions/new.tsx +++ b/frontend/src/pages/questions/new.tsx @@ -36,6 +36,7 @@ export default function NewQuestion() { if (!currentUser || !isAdmin) { router.push("/"); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [authIsReady, currentUser]) diff --git a/frontend/src/pages/settings/_account.tsx b/frontend/src/pages/settings/_account.tsx index 3a67df03..ab4ff571 100644 --- a/frontend/src/pages/settings/_account.tsx +++ b/frontend/src/pages/settings/_account.tsx @@ -4,7 +4,7 @@ import { Label } from "@radix-ui/react-dropdown-menu"; import { useDeleteOwnAccount } from "@/firebase-client/useDeleteOwnAccount"; import { useUser } from "@/hooks/useUser"; import { AuthContext } from "@/contexts/AuthContext"; -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { TypographyH3 } from "@/components/ui/typography"; import { EditableUser } from "@/types/UserTypes"; @@ -19,12 +19,15 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog" +import { useUpdateProfile } from "../../firebase-client/useUpdateProfile"; export default function AccountSettingsCard() { const { user: currentUser } = useContext(AuthContext); const { deleteOwnAccount } = useDeleteOwnAccount(); - const { updateUser } = useUser(); + const { updateUserProfile } = useUpdateProfile(); + const saveButtonRef = useRef(null); + const [showSuccess, setShowSuccess] = useState(false); console.log(currentUser); const [updatedUser, setUpdatedUser] = useState({ uid: currentUser?.uid ?? '' } as EditableUser); @@ -63,7 +66,26 @@ export default function AccountSettingsCard() { className="max-w-sm" /> - +
+ + {showSuccess && ( + Successfully updated user profile! + )} +
+
Danger Zone diff --git a/frontend/src/pages/settings/_match.tsx b/frontend/src/pages/settings/_match.tsx index 00b7f2a0..a48fb2a8 100644 --- a/frontend/src/pages/settings/_match.tsx +++ b/frontend/src/pages/settings/_match.tsx @@ -2,56 +2,102 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Label } from "@radix-ui/react-dropdown-menu"; import { useUser } from "@/hooks/useUser"; import { AuthContext } from "@/contexts/AuthContext"; -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { EditableUser } from "@/types/UserTypes"; import DifficultySelector from "@/components/common/difficulty-selector"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Difficulty } from "@/types/QuestionTypes"; +import { DotWave } from "@uiball/loaders"; export default function MatchSettingsCard() { const { user: currentUser } = useContext(AuthContext); - const { updateUser } = useUser(); + const { updateUser, getAppUser } = useUser(); + const [isLoading, setIsLoading] = useState(true); + const [showSuccess, setShowSuccess] = useState(false); + const submitButtonRef = useRef(null); const [updatedUser, setUpdatedUser] = useState({ uid: currentUser?.uid ?? '' } as EditableUser); const [selectedDifficulty, setSelectedDifficulty] = useState('medium'); + const [selectedLanguage, setSelectedLanguage] = useState('c++'); useEffect(() => { - console.log(updatedUser); - }, [updatedUser]); + if (currentUser) { + getAppUser().then((user) => { + if (user) { + setSelectedDifficulty(user.matchDifficulty); + setSelectedLanguage(user.matchProgrammingLanguage); + } + setIsLoading(false); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser]) useEffect(() => { - console.log(selectedDifficulty); + setUpdatedUser((prev) => ({ ...prev, matchDifficulty: selectedDifficulty })); }, [selectedDifficulty]); + useEffect(() => { + setUpdatedUser((prev) => ({ ...prev, matchProgrammingLanguage: selectedLanguage })); + }, [selectedLanguage]); + return ( Match Preferences + {isLoading ? ( +
+ +
+ ) : (
- { + setShowSuccess(false); + setSelectedLanguage(lang); + }}> Python - Javascript + {/* Javascript */} Java - c++ + C++
- - setSelectedDifficulty(value)} /> + + { + setShowSuccess(false); + setSelectedDifficulty(value); + }} /> +
+
+ + {showSuccess && ( + Successfully updated match preferences! + )}
-
+ )} +
) diff --git a/frontend/src/types/UserTypes.ts b/frontend/src/types/UserTypes.ts index 2eb25533..bf980e18 100644 --- a/frontend/src/types/UserTypes.ts +++ b/frontend/src/types/UserTypes.ts @@ -2,7 +2,7 @@ export type EditableUser = { uid: string; displayName?: string | null; photoUrl?: string | null; - matchDifficulty?: number | null; + matchDifficulty?: string | null; matchProgrammingLanguage?: string | null; }; diff --git a/prisma/migrations/20231031082108_change_match_difficulty_to_string/migration.sql b/prisma/migrations/20231031082108_change_match_difficulty_to_string/migration.sql new file mode 100644 index 00000000..6fc0d38d --- /dev/null +++ b/prisma/migrations/20231031082108_change_match_difficulty_to_string/migration.sql @@ -0,0 +1,54 @@ +/* + Warnings: + + - A unique constraint covering the columns `[attempt_id]` on the table `Room` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "AppUser" ALTER COLUMN "matchDifficulty" SET DATA TYPE TEXT; + +-- AlterTable +ALTER TABLE "Match" ADD COLUMN "questionId" TEXT; + +-- AlterTable +ALTER TABLE "Room" ADD COLUMN "active_users" TEXT[], +ADD COLUMN "attempt_id" TEXT, +ADD COLUMN "question_id" TEXT; + +-- CreateTable +CREATE TABLE "Attempt" ( + "id" TEXT NOT NULL, + "question_id" TEXT NOT NULL, + "answer" TEXT, + "solved" BOOLEAN NOT NULL DEFAULT false, + "time_saved_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "room_id" TEXT, + "time_updated" TIMESTAMP(3) NOT NULL, + "time_created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Attempt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_AppUserToAttempt" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_AppUserToAttempt_AB_unique" ON "_AppUserToAttempt"("A", "B"); + +-- CreateIndex +CREATE INDEX "_AppUserToAttempt_B_index" ON "_AppUserToAttempt"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "Room_attempt_id_key" ON "Room"("attempt_id"); + +-- AddForeignKey +ALTER TABLE "Room" ADD CONSTRAINT "Room_attempt_id_fkey" FOREIGN KEY ("attempt_id") REFERENCES "Attempt"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_AppUserToAttempt" ADD CONSTRAINT "_AppUserToAttempt_A_fkey" FOREIGN KEY ("A") REFERENCES "AppUser"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_AppUserToAttempt" ADD CONSTRAINT "_AppUserToAttempt_B_fkey" FOREIGN KEY ("B") REFERENCES "Attempt"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9de8f6cb..6c082575 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,6 +1,3 @@ -// 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" } @@ -31,37 +28,37 @@ model AppUser { uid String @id displayName String? photoUrl String? - matchDifficulty Int? + matchDifficulty String? matchProgrammingLanguage String? - attempts Attempt[] + attempts Attempt[] @relation("AppUserToAttempt") } model Room { room_id String @id - active_users String[] // Array of user_id strings still active - users String[] // Array of user_id strings + users String[] status EnumRoomStatus text String saved_text String? question_id String? - attempt Attempt? @relation(fields: [attempt_id], references: [id]) attempt_id String? @unique -} - -enum EnumRoomStatus { - active - inactive + active_users String[] + attempt Attempt? @relation(fields: [attempt_id], references: [id]) } model Attempt { id String @id @default(uuid()) - users AppUser[] question_id String answer String? solved Boolean @default(false) + time_saved_at DateTime @default(now()) + room_id String? + time_updated DateTime @updatedAt time_created DateTime @default(now()) - time_saved_at DateTime @default(now()) // when answers are updated - time_updated DateTime @updatedAt // any field change - room_id String? // may be inactive room Room? + users AppUser[] @relation("AppUserToAttempt") +} + +enum EnumRoomStatus { + active + inactive } diff --git a/services/admin-service/.gitignore b/services/admin-service/.gitignore new file mode 100644 index 00000000..ce597ce5 --- /dev/null +++ b/services/admin-service/.gitignore @@ -0,0 +1,64 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Ignore built ts files +dist/**/* + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next diff --git a/services/gateway/.gitignore b/services/gateway/.gitignore new file mode 100644 index 00000000..ce597ce5 --- /dev/null +++ b/services/gateway/.gitignore @@ -0,0 +1,64 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Ignore built ts files +dist/**/* + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next diff --git a/services/matching-service/src/swagger-output.json b/services/matching-service/src/swagger-output.json index 6e329ba1..b5d54982 100644 --- a/services/matching-service/src/swagger-output.json +++ b/services/matching-service/src/swagger-output.json @@ -66,7 +66,71 @@ } } }, - "/api/matching-service/": { + "/api/matching-service/match/{room_id}": { + "get": { + "description": "", + "parameters": [ + { + "name": "room_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + } + } + }, + "patch": { + "description": "", + "parameters": [ + { + "name": "room_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "questionId": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/matching-service/demo": { "get": { "description": "", "responses": { diff --git a/services/user-service/src/db/functions.ts b/services/user-service/src/db/functions.ts index 7ebedf81..7eaf01fe 100644 --- a/services/user-service/src/db/functions.ts +++ b/services/user-service/src/db/functions.ts @@ -75,6 +75,20 @@ const userDatabaseFunctions = { } }, + async getAttemptById(attemptId: string) { + try { + const attempt = await prismaClient.attempt.findUnique({ + where: { + id: attemptId, + }, + }); + return attempt; + } catch (error: any) { + console.error(`Error retrieving attempt: ${error.message}`); + throw error; + } + }, + async createAttemptOfUser(data: { uid: string; question_id: string; @@ -111,6 +125,27 @@ const userDatabaseFunctions = { throw error; } }, + + async setMatchPreferenceOfUser(uid: string, data: { + matchDifficulty: string; + matchProgrammingLanguage: string; + }) { + try { + const updatedResult = await prismaClient.appUser.update({ + where: { + uid: uid, + }, + data: { + matchDifficulty: data["matchDifficulty"], + matchProgrammingLanguage: data["matchProgrammingLanguage"], + }, + }); + return updatedResult; + } catch (error: any) { + console.error(`Error setting match preference: ${error.message}`); + throw error; + } + } }; export default userDatabaseFunctions; diff --git a/services/user-service/src/routes/index.ts b/services/user-service/src/routes/index.ts index e540ef92..16eb45b1 100644 --- a/services/user-service/src/routes/index.ts +++ b/services/user-service/src/routes/index.ts @@ -115,6 +115,26 @@ indexRouter.get( function (req: express.Request, res: express.Response) { userDatabaseFunctions .getAttemptsOfUser(req.params.uid) + .then((result) => { + if (result === null) { + // res.status(404).end(); + res.send(200).json([]); + } else { + res.status(200).json(result); + } + }) + .catch(() => { + // Server side error such as database not being available + res.status(500).end(); + }); + } +); + +indexRouter.get( + "/attempt/:attempt_id", + function (req: express.Request, res: express.Response) { + userDatabaseFunctions + .getAttemptById(req.params.attempt_id) .then((result) => { if (result === null) { res.status(404).end(); diff --git a/services/user-service/src/swagger-output.json b/services/user-service/src/swagger-output.json index 3dce4a77..afcf18a9 100644 --- a/services/user-service/src/swagger-output.json +++ b/services/user-service/src/swagger-output.json @@ -120,6 +120,29 @@ } } ], + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/user-service/attempt/{attempt_id}": { + "get": { + "description": "", + "parameters": [ + { + "name": "attempt_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "OK" diff --git a/services/user-service/systemtest/app.test.ts b/services/user-service/systemtest/app.test.ts index eaf746c0..cb249dd7 100644 --- a/services/user-service/systemtest/app.test.ts +++ b/services/user-service/systemtest/app.test.ts @@ -4,13 +4,13 @@ import app from "../src/app" import request from 'supertest'; -const fullNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: 0, - matchProgrammingLanguage: "Python" }; +const fullNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: "easy", + matchProgrammingLanguage: "python" }; -const updatedNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: 1, - matchProgrammingLanguage: "Python"}; +const updatedNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: "medium", + matchProgrammingLanguage: "python"}; -const updatePayload = { matchDifficulty: 1 }; +const updatePayload = { matchDifficulty: "medium" }; const userIdHeader = "User-Id"; diff --git a/services/user-service/test/db/functions.test.ts b/services/user-service/test/db/functions.test.ts index 0726d726..4e7d02c2 100644 --- a/services/user-service/test/db/functions.test.ts +++ b/services/user-service/test/db/functions.test.ts @@ -4,8 +4,8 @@ import prismaMock from '../../src/db/__mocks__/prismaClient' vi.mock('../../src/db/prismaClient') -const fullNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: 0, - matchProgrammingLanguage: "Python" }; +const fullNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: "easy", + matchProgrammingLanguage: "python" }; const partialNewUser = { uid: '1'}; diff --git a/services/user-service/test/routes/index.test.ts b/services/user-service/test/routes/index.test.ts index 6d2b5abd..0720302d 100644 --- a/services/user-service/test/routes/index.test.ts +++ b/services/user-service/test/routes/index.test.ts @@ -12,8 +12,8 @@ const app = express(); const userIdHeader = "User-Id"; app.use(indexRouter); -const fullNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: 0, - matchProgrammingLanguage: "Python" }; +const fullNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: "easy", + matchProgrammingLanguage: "python" }; describe('/index', () => { /** From 19bb77db88584099094728eab9c3e9baee1a91ae Mon Sep 17 00:00:00 2001 From: Ong Jun Xiong Date: Thu, 2 Nov 2023 18:33:07 +0800 Subject: [PATCH 30/50] handle more error cases for userHandler --- bash.exe.stackdump | 44 +++++++++++++++++++++++++++ frontend/src/pages/api/userHandler.ts | 27 +++++++++++----- 2 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 bash.exe.stackdump diff --git a/bash.exe.stackdump b/bash.exe.stackdump new file mode 100644 index 00000000..deaedace --- /dev/null +++ b/bash.exe.stackdump @@ -0,0 +1,44 @@ +Exception: STATUS_STACK_OVERFLOW at rip=0010040B625 +rax=0000000000000000 rbx=00000000FFE04040 rcx=0000000000000000 +rdx=0000000000000000 rsi=00000001004F73A0 rdi=00000000000000C8 +r8 =0000000000000000 r9 =00000000FFFFDE08 r10=00000000FFFFE458 +r11=0000000100000000 r12=0000000000000000 r13=0000000800641E70 +r14=0000000000000000 r15=00000000FFFFFFFF +rbp=0000000000000000 rsp=00000000FFE03E40 +program=C:\Program Files\Git\usr\bin\bash.exe, pid 1795, thread +cs=0033 ds=002B es=002B fs=0053 gs=002B ss=002B +Stack trace: +Frame Function Args +00000000000 0010040B625 (001004036F5, 00000000010, 00000000000, 000FFE04DB0) +00000000010 00100401FFA (00210199B0B, 00800082CE0, 00800642000, 00800641E70) +00000000010 0010046ED5C (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +00800641CB0 00100417AD9 (00210199B0B, 00800082CE0, 008006415A0, 00800641CB0) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +00800641250 00100417AD9 (00210199B0B, 00800082CE0, 00800640B40, 00800641250) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +008006407F0 00100417AD9 (00210199B0B, 00800082CE0, 008006400E0, 008006407F0) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +0080063FD90 00100417AD9 (00210199B0B, 00800082CE0, 0080063F680, 0080063FD90) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +0080063F330 00100417AD9 (00210199B0B, 00800082CE0, 0080063EC20, 0080063F330) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +0080063E8D0 00100417AD9 (00210199B0B, 00800082CE0, 0080063E1C0, 0080063E8D0) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +0080063DE70 00100417AD9 (00210199B0B, 00800082CE0, 0080063D760, 0080063DE70) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +0080063D410 00100417AD9 (00210199B0B, 00800082CE0, 0080063CD00, 0080063D410) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +0080063C9B0 00100417AD9 (00210199B0B, 00800082CE0, 0080063C2A0, 0080063C9B0) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +0080063BF50 00100417AD9 (00210199B0B, 00800082CE0, 0080063B840, 0080063BF50) +End of stack trace (more stack frames may be present) diff --git a/frontend/src/pages/api/userHandler.ts b/frontend/src/pages/api/userHandler.ts index 02586dce..05acf086 100644 --- a/frontend/src/pages/api/userHandler.ts +++ b/frontend/src/pages/api/userHandler.ts @@ -14,14 +14,19 @@ export const updateUserByUid = async (user: EditableUser, currentUser: any) => { headers: { "Content-Type": "application/json", "User-Id-Token": idToken, - "User-Id": currentUser.uid + "User-Id": currentUser.uid, }, body: JSON.stringify(user), }); - const data = await response.json(); if (response.status === 201) { + const data = await response.json(); return data; + } else { + const text = await response.text(); + throw new Error( + `Unexpected response (status: ${response.status}): ${text}` + ); } } catch (error) { console.error("There was an error updating the user", error); @@ -40,18 +45,26 @@ export const getUserByUid = async (uid: string, currentUser: any) => { headers: { "Content-Type": "application/json", "User-Id-Token": idToken, - "User-Id": currentUser.uid + "User-Id": currentUser.uid, }, }); - const data = await response.json(); - if (response.status === 200) { + if ( + response.ok && + response.headers.get("Content-Type")?.includes("application/json") + ) { + const data = await response.json(); return data; + } else if (response.status === 204) { + return null; } else { - throw new Error(response.statusText); + const text = await response.text(); + throw new Error( + `Unexpected response (status: ${response.status}): ${text}` + ); } } catch (error) { console.error("There was an error getting the user", error); throw error; } -}; \ No newline at end of file +}; From f17ce4513a2ff2089a9e52889667e3e9cd0de00b Mon Sep 17 00:00:00 2001 From: Ong Jun Xiong Date: Thu, 2 Nov 2023 18:33:33 +0800 Subject: [PATCH 31/50] delete dumps --- bash.exe.stackdump | 44 -------------------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 bash.exe.stackdump diff --git a/bash.exe.stackdump b/bash.exe.stackdump deleted file mode 100644 index deaedace..00000000 --- a/bash.exe.stackdump +++ /dev/null @@ -1,44 +0,0 @@ -Exception: STATUS_STACK_OVERFLOW at rip=0010040B625 -rax=0000000000000000 rbx=00000000FFE04040 rcx=0000000000000000 -rdx=0000000000000000 rsi=00000001004F73A0 rdi=00000000000000C8 -r8 =0000000000000000 r9 =00000000FFFFDE08 r10=00000000FFFFE458 -r11=0000000100000000 r12=0000000000000000 r13=0000000800641E70 -r14=0000000000000000 r15=00000000FFFFFFFF -rbp=0000000000000000 rsp=00000000FFE03E40 -program=C:\Program Files\Git\usr\bin\bash.exe, pid 1795, thread -cs=0033 ds=002B es=002B fs=0053 gs=002B ss=002B -Stack trace: -Frame Function Args -00000000000 0010040B625 (001004036F5, 00000000010, 00000000000, 000FFE04DB0) -00000000010 00100401FFA (00210199B0B, 00800082CE0, 00800642000, 00800641E70) -00000000010 0010046ED5C (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -00800641CB0 00100417AD9 (00210199B0B, 00800082CE0, 008006415A0, 00800641CB0) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -00800641250 00100417AD9 (00210199B0B, 00800082CE0, 00800640B40, 00800641250) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -008006407F0 00100417AD9 (00210199B0B, 00800082CE0, 008006400E0, 008006407F0) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -0080063FD90 00100417AD9 (00210199B0B, 00800082CE0, 0080063F680, 0080063FD90) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -0080063F330 00100417AD9 (00210199B0B, 00800082CE0, 0080063EC20, 0080063F330) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -0080063E8D0 00100417AD9 (00210199B0B, 00800082CE0, 0080063E1C0, 0080063E8D0) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -0080063DE70 00100417AD9 (00210199B0B, 00800082CE0, 0080063D760, 0080063DE70) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -0080063D410 00100417AD9 (00210199B0B, 00800082CE0, 0080063CD00, 0080063D410) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -0080063C9B0 00100417AD9 (00210199B0B, 00800082CE0, 0080063C2A0, 0080063C9B0) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -0080063BF50 00100417AD9 (00210199B0B, 00800082CE0, 0080063B840, 0080063BF50) -End of stack trace (more stack frames may be present) From dce34a73664f953d6c65d12aa1c45b3fc256b147 Mon Sep 17 00:00:00 2001 From: chunweii <47494777+chunweii@users.noreply.github.com> Date: Thu, 2 Nov 2023 19:24:04 +0800 Subject: [PATCH 32/50] Fix incorrect status code issue on frontend --- frontend/src/pages/api/userHandler.ts | 2 +- services/user-service/src/routes/index.ts | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/api/userHandler.ts b/frontend/src/pages/api/userHandler.ts index 05acf086..f811880d 100644 --- a/frontend/src/pages/api/userHandler.ts +++ b/frontend/src/pages/api/userHandler.ts @@ -19,7 +19,7 @@ export const updateUserByUid = async (user: EditableUser, currentUser: any) => { body: JSON.stringify(user), }); - if (response.status === 201) { + if (response.status === 200) { const data = await response.json(); return data; } else { diff --git a/services/user-service/src/routes/index.ts b/services/user-service/src/routes/index.ts index 16eb45b1..15f608c2 100644 --- a/services/user-service/src/routes/index.ts +++ b/services/user-service/src/routes/index.ts @@ -31,8 +31,9 @@ indexRouter.get( res.status(200).json(result); } }) - .catch(() => { + .catch((err) => { // Server side error such as database not being available + console.log(err); res.status(500).end(); }); } @@ -63,6 +64,7 @@ indexRouter.put( res.status(200).json(result); }) .catch((error) => { + console.log(error); if (error.code === "P2025") { res.status(404).end(); } else { @@ -99,6 +101,7 @@ indexRouter.delete( res.status(204).end(); }) .catch((error) => { + console.log(error); if (error.code === "P2025") { res.status(404).end(); } else { @@ -123,7 +126,8 @@ indexRouter.get( res.status(200).json(result); } }) - .catch(() => { + .catch((err) => { + console.log(err); // Server side error such as database not being available res.status(500).end(); }); @@ -142,7 +146,8 @@ indexRouter.get( res.status(200).json(result); } }) - .catch(() => { + .catch((err) => { + console.log(err); // Server side error such as database not being available res.status(500).end(); }); From b2e18f04cba338e8b99490e41d217beb05c7d8b2 Mon Sep 17 00:00:00 2001 From: chunweii <47494777+chunweii@users.noreply.github.com> Date: Thu, 2 Nov 2023 19:41:29 +0800 Subject: [PATCH 33/50] Fix deleting account --- frontend/src/firebase-client/useDeleteOwnAccount.ts | 10 +++++++++- frontend/src/pages/settings/_account.tsx | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/firebase-client/useDeleteOwnAccount.ts b/frontend/src/firebase-client/useDeleteOwnAccount.ts index 11fa14a2..355a46fb 100644 --- a/frontend/src/firebase-client/useDeleteOwnAccount.ts +++ b/frontend/src/firebase-client/useDeleteOwnAccount.ts @@ -2,6 +2,7 @@ import { auth } from "./firebase_config"; import { AuthContext } from "../contexts/AuthContext"; import { useContext } from "react"; import { userApiPathAddress } from "@/gateway-address/gateway-address"; +import { GithubAuthProvider, reauthenticateWithCredential, signInWithPopup } from "firebase/auth"; export const useDeleteOwnAccount = () => { const { dispatch } = useContext(AuthContext); @@ -10,7 +11,14 @@ export const useDeleteOwnAccount = () => { const currentUser = auth.currentUser; if (currentUser) { const idToken = await currentUser.getIdToken(true); - + const provider = new GithubAuthProvider(); + const oAuthResult = await signInWithPopup(auth, provider); + const credential = GithubAuthProvider.credentialFromResult(oAuthResult); + if (!credential) { + throw new Error("Could not get credential"); + } + await reauthenticateWithCredential(currentUser, credential); + await fetch(userApiPathAddress + currentUser.uid, { method: "DELETE", headers: { diff --git a/frontend/src/pages/settings/_account.tsx b/frontend/src/pages/settings/_account.tsx index ab4ff571..4c870075 100644 --- a/frontend/src/pages/settings/_account.tsx +++ b/frontend/src/pages/settings/_account.tsx @@ -99,7 +99,7 @@ export default function AccountSettingsCard() { Are you absolutely sure? This action cannot be undone. This will permanently delete your account - and remove your data from our servers. + and remove your data from our servers. Please log in to GitHub again to continue. From 8afd0575bc7661e6d500fce2d3111ff2f4054ca5 Mon Sep 17 00:00:00 2001 From: Ong Jun Xiong Date: Thu, 2 Nov 2023 22:07:43 +0800 Subject: [PATCH 34/50] remove redundant matching details --- .../src/controllers/matchingController.ts | 170 ------------------ .../src/routes/matchingRoutes.ts | 10 +- 2 files changed, 1 insertion(+), 179 deletions(-) diff --git a/services/matching-service/src/controllers/matchingController.ts b/services/matching-service/src/controllers/matchingController.ts index 0f39b063..0151ca91 100644 --- a/services/matching-service/src/controllers/matchingController.ts +++ b/services/matching-service/src/controllers/matchingController.ts @@ -390,176 +390,6 @@ export function handleSendMessage( }; } -export const findMatch = async (req: Request, res: Response) => { - const io: Server = req.app.get("io"); - - const userId = req.params.userId; - const difficulties = req.body.difficulties || ["easy", "medium", "hard"]; - const programming_language = req.body.programming_language || "python"; - - const user = await prisma.user.findUnique({ - where: { id: userId }, - }); - - if (!user) { - return res.status(404).json({ error: "User not found" }); - } - - // Check if the user is already matched with another user - if (user.matchedUserId) { - const matchedUser = await prisma.user.findUnique({ - where: { id: user.matchedUserId }, - }); - - if (matchedUser) { - // Check for timeout or if the matched user has left - const now = new Date(); - const sixtySecondsAgo = new Date(now.getTime() - 60 * 1000); - - if ( - !user.lastConnected || - user.lastConnected < sixtySecondsAgo || - !matchedUser.matchedUserId - ) { - // Break the match and update both users' status - await prisma.user.update({ - where: { id: userId }, - data: { matchedUserId: null, lastConnected: null }, - }); - - if (matchedUser.matchedUserId) { - await prisma.user.update({ - where: { id: matchedUser.id }, - data: { matchedUserId: null, lastConnected: null }, - }); - } - } else { - // Update the lastConnected timestamp and reconnect the users - const now = new Date(); - await prisma.user.update({ - where: { id: userId }, - data: { - lastConnected: now, - }, - }); - - await prisma.user.update({ - where: { id: matchedUser.id }, - data: { - lastConnected: now, - }, - }); - - // Emit match found event to both users - io.to(userId.toString()).emit("matchFound", matchedUser); - io.to(matchedUser.id.toString()).emit("matchFound", user); - return res.json({ match: matchedUser }); - } - } - } - - if (user.isLookingForMatch) { - return res - .status(400) - .json({ error: "User is already looking for a match" }); - } - - // Update user status to looking for a match - await prisma.user.update({ - where: { id: userId }, - data: { isLookingForMatch: true }, - }); - - // Try to find a match - const match = await prisma.user.findFirst({ - where: { isLookingForMatch: true, id: { not: userId } }, - }); - - if (match) { - // Both users are matched - await prisma.user.update({ - where: { id: userId }, - data: { - isLookingForMatch: false, - matchedUserId: match.id, - lastConnected: new Date(), - }, - }); - - await prisma.user.update({ - where: { id: match.id }, - data: { - isLookingForMatch: false, - matchedUserId: userId, - lastConnected: new Date(), - }, - }); - - // This function and REST API seems to be not in use - const questionId = await getRandomQuestionOfDifficulty( - difficulties[0] - ).then( - // difficulties???? need to intersect difficulties or not - (questionId) => { - return questionId; - } - ); - - // Emit match found event to both users - io.to(userId.toString()).emit("matchFound", { match, questionId }); - io.to(match.id.toString()).emit("matchFound", { user, questionId }); - - return res.json({ match, questionId }); - } - - // If no immediate match is found, keep the user in the queue - return res.status(202).json({ message: "Looking for a match, please wait." }); -}; - -export const leaveMatch = async (req: Request, res: Response) => { - const userId = req.params.userId; - const user = await prisma.user.findUnique({ - where: { id: userId }, - }); - - if (!user) { - return res.status(404).json({ error: "User not found" }); - } - - if (!user.matchedUserId) { - return res.status(400).json({ error: "User is not in a match" }); - } - - // Update both users' status - await prisma.user.update({ - where: { id: userId }, - data: { matchedUserId: null, lastConnected: null }, - }); - - await prisma.user.update({ - where: { id: user.matchedUserId }, - data: { matchedUserId: null, lastConnected: null }, - }); - - res.status(200).json({ message: "Successfully left the match" }); -}; - -export async function getMatch(req: Request, res: Response) { - const room_id = req.params.room_id as string; - - const match = await prisma.match.findUnique({ where: { roomId: room_id } }); - - if (!match) { - return res.status(404).json({ error: "Match not found" }); - } - - return res.status(200).json({ - message: "Match exists", - room_id: room_id, - info: match, - }); -} - export async function updateMatchQuestion(req: Request, res: Response) { const room_id = req.params.room_id as string; diff --git a/services/matching-service/src/routes/matchingRoutes.ts b/services/matching-service/src/routes/matchingRoutes.ts index bda0ebcc..9707c5f0 100644 --- a/services/matching-service/src/routes/matchingRoutes.ts +++ b/services/matching-service/src/routes/matchingRoutes.ts @@ -1,16 +1,8 @@ import express from "express"; -import { - findMatch, - getMatch, - leaveMatch, - updateMatchQuestion, -} from "../controllers/matchingController"; +import { updateMatchQuestion } from "../controllers/matchingController"; const router = express.Router(); -router.get("/:userId/findMatch", findMatch); -router.post("/:userId/leave", leaveMatch); -router.get("/match/:room_id", getMatch); router.patch("/match/:room_id", updateMatchQuestion); router.get("/demo", (req, res) => res.sendFile(__dirname + "/index.html")); From 77f3f29465ec8a69105f4423bc61fb333636a4c6 Mon Sep 17 00:00:00 2001 From: Ong Jun Xiong Date: Thu, 2 Nov 2023 23:32:13 +0800 Subject: [PATCH 35/50] update find match logic --- bash.exe.stackdump | 44 ++++++ .../src/firebase-client/useUpdateProfile.ts | 30 ++-- frontend/src/hooks/useUser.ts | 13 +- frontend/src/pages/interviews/match-found.tsx | 41 +++-- frontend/src/pages/profile/[id]/index.tsx | 48 +++--- frontend/src/pages/profile/_profile.tsx | 86 +++++++---- frontend/src/pages/settings/_account.tsx | 91 +++++++---- frontend/src/pages/settings/_match.tsx | 145 +++++++++++------- 8 files changed, 333 insertions(+), 165 deletions(-) create mode 100644 bash.exe.stackdump diff --git a/bash.exe.stackdump b/bash.exe.stackdump new file mode 100644 index 00000000..08ebf9cf --- /dev/null +++ b/bash.exe.stackdump @@ -0,0 +1,44 @@ +Exception: STATUS_STACK_OVERFLOW at rip=0010040B625 +rax=0000000000000000 rbx=00000000FFE04040 rcx=0000000000000000 +rdx=0000000000000000 rsi=00000001004F73A0 rdi=00000000000000C8 +r8 =0000000000000000 r9 =00000000FFFFDE08 r10=00000000FFFFE458 +r11=0000000100000000 r12=0000000000000000 r13=00000008006429D0 +r14=0000000000000000 r15=00000000FFFFFFFF +rbp=0000000000000000 rsp=00000000FFE03E40 +program=C:\Program Files\Git\usr\bin\bash.exe, pid 2063, thread +cs=0033 ds=002B es=002B fs=0053 gs=002B ss=002B +Stack trace: +Frame Function Args +00000000000 0010040B625 (001004036F5, 00000000010, 00000000000, 000FFE04DB0) +00000000010 00100401FFA (00210199B0B, 00800082CE0, 00800642B60, 008006429D0) +00000000010 0010046ED5C (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +00800642810 00100417AD9 (00210199B0B, 00800082CE0, 00800642100, 00800642810) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +00800641DB0 00100417AD9 (00210199B0B, 00800082CE0, 008006416A0, 00800641DB0) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +00800641350 00100417AD9 (00210199B0B, 00800082CE0, 00800640C40, 00800641350) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +008006408F0 00100417AD9 (00210199B0B, 00800082CE0, 008006401E0, 008006408F0) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +0080063FE90 00100417AD9 (00210199B0B, 00800082CE0, 0080063F780, 0080063FE90) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +0080063F430 00100417AD9 (00210199B0B, 00800082CE0, 0080063ED20, 0080063F430) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +0080063E9D0 00100417AD9 (00210199B0B, 00800082CE0, 0080063E2C0, 0080063E9D0) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +0080063DF70 00100417AD9 (00210199B0B, 00800082CE0, 0080063D860, 0080063DF70) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +0080063D510 00100417AD9 (00210199B0B, 00800082CE0, 0080063CE00, 0080063D510) +00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) +00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) +0080063CAB0 00100417AD9 (00210199B0B, 00800082CE0, 0080063C3A0, 0080063CAB0) +End of stack trace (more stack frames may be present) diff --git a/frontend/src/firebase-client/useUpdateProfile.ts b/frontend/src/firebase-client/useUpdateProfile.ts index 98118e2c..a36368e2 100644 --- a/frontend/src/firebase-client/useUpdateProfile.ts +++ b/frontend/src/firebase-client/useUpdateProfile.ts @@ -1,15 +1,21 @@ +import { updateUserByUid } from "@/pages/api/userHandler"; import { getAuth, updateProfile } from "firebase/auth"; export const useUpdateProfile = () => { - - const updateUserProfile = async ({displayName, photoURL}: {displayName?: string, photoURL?: string}) => { - const auth = getAuth(); - try { - await updateProfile(auth.currentUser!, { displayName, photoURL }); - } catch (error) { - console.log(error); - } - }; - - return { updateUserProfile }; -}; \ No newline at end of file + const updateUserProfile = async ({ + displayName, + photoURL, + }: { + displayName?: string; + photoURL?: string; + }) => { + const auth = getAuth(); + try { + await updateProfile(auth.currentUser!, { displayName, photoURL }); + } catch (error) { + console.log(error); + } + }; + + return { updateUserProfile }; +}; diff --git a/frontend/src/hooks/useUser.ts b/frontend/src/hooks/useUser.ts index 18704553..6d09ab38 100644 --- a/frontend/src/hooks/useUser.ts +++ b/frontend/src/hooks/useUser.ts @@ -1,5 +1,8 @@ import { useContext } from "react"; -import { updateUserByUid as updateUserApi, getUserByUid as getUserApi } from "./../pages/api/userHandler"; +import { + updateUserByUid as updateUserApi, + getUserByUid as getUserApi, +} from "./../pages/api/userHandler"; import { AuthContext } from "@/contexts/AuthContext"; import { EditableUser } from "@/types/UserTypes"; @@ -12,9 +15,13 @@ export const useUser = () => { } }; - const getAppUser = async (userId?: string) => { + const getAppUser = async (userId?: string, fetchSelf: boolean = true) => { if (authIsReady) { - return getUserApi(userId || currentUser?.uid || "", currentUser); + if (fetchSelf) { + return getUserApi(currentUser?.uid || "", currentUser); + } else { + return getUserApi(userId || "", currentUser); + } } }; diff --git a/frontend/src/pages/interviews/match-found.tsx b/frontend/src/pages/interviews/match-found.tsx index 0b0f3228..46ff2429 100644 --- a/frontend/src/pages/interviews/match-found.tsx +++ b/frontend/src/pages/interviews/match-found.tsx @@ -6,26 +6,47 @@ import { TypographyH2, TypographyH3, } from "@/components/ui/typography"; +import { AuthContext } from "@/contexts/AuthContext"; import { useMatchmaking } from "@/hooks/useMatchmaking"; +import { useUser } from "@/hooks/useUser"; import { query } from "express"; import Link from "next/link"; import { useRouter } from "next/router"; +import { useContext, useEffect, useState } from "react"; type UserInfo = { - name: string; - username: string; - avatar: string; + displayName: string; + photoUrl: string; }; const defaultUser: UserInfo = { - name: "John Doe", - username: "johndoe", - avatar: "https://github.com/shadcn.png", + displayName: "John Doe", + photoUrl: "https://github.com/shadcn.png", }; export default function MatchFound() { const router = useRouter(); const { match, leaveMatch, joinQueue, cancelLooking } = useMatchmaking(); + const { user, authIsReady } = useContext(AuthContext); + const [otherUser, setOtherUser] = useState(defaultUser); + + const { getAppUser } = useUser(); + + useEffect(() => { + const fetchOtherUser = async () => { + const otherUserId = + match?.userId1 === user?.uid ? match?.userId2 : match?.userId1; + + const other = await getAppUser(otherUserId, false); + setOtherUser(other); + + console.log(other); + }; + + if (user && authIsReady) { + fetchOtherUser(); + } + }, [user, authIsReady, match]); const onClickCancel = () => { leaveMatch(); @@ -49,14 +70,14 @@ export default function MatchFound() {
- + - {defaultUser.name.charAt(0).toUpperCase()} + {defaultUser.displayName.charAt(0).toUpperCase()}
- {defaultUser?.name} - @{defaultUser?.username} + {otherUser?.displayName ?? "Annoymous"} + {/* @{otherUser?.displayName} */}
diff --git a/frontend/src/pages/profile/[id]/index.tsx b/frontend/src/pages/profile/[id]/index.tsx index 7f0bb304..c6fec8c3 100644 --- a/frontend/src/pages/profile/[id]/index.tsx +++ b/frontend/src/pages/profile/[id]/index.tsx @@ -10,35 +10,47 @@ import { useUser } from "@/hooks/useUser"; export default function Page() { const router = useRouter(); const id = router.query.id; + const { user: authUser, authIsReady } = useContext(AuthContext); const { getAppUser } = useUser(); const { user: currentUser } = useContext(AuthContext); const { fetchAttempts } = useHistory(); const [attempts, setAttempts] = useState([]); const [user, setUser] = useState(); - const [loadingState, setLoadingState] = useState<"loading" | "error" | "success">("loading"); + const [loadingState, setLoadingState] = useState< + "loading" | "error" | "success" + >("loading"); useEffect(() => { - if (currentUser && (typeof id === "string")) { - Promise.all([getAppUser(id), fetchAttempts(id)]).then(([user, attempts]) => { - if (user && attempts) { - user["photoURL"] = user["photoUrl"]; - console.log(user); - setUser(user); - setAttempts(attempts); - setLoadingState("success"); - } else { - throw new Error("User or attempts not found"); - } - }).catch((err: any) => { - setLoadingState("error"); - console.log(err); - }); + if (currentUser && typeof id === "string") { + Promise.all([getAppUser(id), fetchAttempts(id)]) + .then(([user, attempts]) => { + if (user && attempts) { + user["photoURL"] = user["photoUrl"]; + console.log(user); + setUser(user); + setAttempts(attempts); + setLoadingState("success"); + } else { + throw new Error("User or attempts not found"); + } + }) + .catch((err: any) => { + setLoadingState("error"); + console.log(err); + }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentUser]); return ( - (user && ) - ) + user && ( + + ) + ); } diff --git a/frontend/src/pages/profile/_profile.tsx b/frontend/src/pages/profile/_profile.tsx index 95be55ed..7ed93154 100644 --- a/frontend/src/pages/profile/_profile.tsx +++ b/frontend/src/pages/profile/_profile.tsx @@ -1,36 +1,47 @@ -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; -import { TypographyBody, TypographyH3, TypographyH2 } from "@/components/ui/typography"; +import { + TypographyBody, + TypographyH3, + TypographyH2, +} from "@/components/ui/typography"; import { Attempt } from "@/types/UserTypes"; import { User } from "firebase/auth"; import Link from "next/link"; import ActivityCalendar, { Activity } from "react-activity-calendar"; -import { Tooltip as MuiTooltip } from '@mui/material'; +import { Tooltip as MuiTooltip } from "@mui/material"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { DataTable } from "@/components/profile/data-table"; import { columns } from "@/components/profile/columns"; import { DotWave } from "@uiball/loaders"; type ProfileProps = { - selectedUser: User, - loadingState: "loading" | "error" | "success", - attempts?: Attempt[], - isCurrentUser: boolean, -} + selectedUser: User; + loadingState: "loading" | "error" | "success"; + attempts?: Attempt[]; + isCurrentUser: boolean; +}; -export default function Profile({ selectedUser, attempts, isCurrentUser, loadingState }: ProfileProps) { +export default function Profile({ + selectedUser, + attempts, + isCurrentUser, + loadingState, +}: ProfileProps) { + console.log(selectedUser); const getInitials = (name: string) => { + if (!name) return "Annonymous"; const names = name.split(" "); let initials = ""; names.forEach((n) => { initials += n[0].toUpperCase(); }); return initials; - } + }; const date = new Date(); const dateTodayString = date.toISOString().slice(0, 10); - date.setFullYear(date.getFullYear() - 1) + date.setFullYear(date.getFullYear() - 1); const dateLastYearString = date.toISOString().slice(0, 10); // We need a date from last year to make sure the calendar is styled properly @@ -55,7 +66,10 @@ export default function Profile({ selectedUser, attempts, isCurrentUser, loading countsByDate[date].level = 1; } else if (countsByDate[date].count > 1 && countsByDate[date].count < 5) { countsByDate[date].level = 2; - } else if (countsByDate[date].count >= 5 && countsByDate[date].count < 10) { + } else if ( + countsByDate[date].count >= 5 && + countsByDate[date].count < 10 + ) { countsByDate[date].level = 3; } else if (countsByDate[date].count >= 10) { countsByDate[date].level = 4; @@ -63,30 +77,33 @@ export default function Profile({ selectedUser, attempts, isCurrentUser, loading }); } - // Extract the values from the dictionary to get the final activities array - const activities = Object.values(countsByDate).sort((a, b) => a.date.localeCompare(b.date)); + const activities = Object.values(countsByDate).sort((a, b) => + a.date.localeCompare(b.date) + ); return (
- - {getInitials(selectedUser?.displayName ?? '')} + + + {getInitials(selectedUser?.displayName ?? "")} +
{selectedUser?.displayName} {selectedUser?.email}
- {isCurrentUser && + {isCurrentUser && ( - } + )}
@@ -99,7 +116,7 @@ export default function Profile({ selectedUser, attempts, isCurrentUser, loading ( )} labels={{ - totalCount: '{{count}} activities in 2023', + totalCount: "{{count}} activities in 2023", }} /> - - Attempts - + Attempts - {loadingState === "loading" ?
- -
: loadingState === "error" ?
- Something went wrong. Please try again later. -
: - } + {loadingState === "loading" ? ( +
+ +
+ ) : loadingState === "error" ? ( +
+ + Something went wrong. Please try again later. + +
+ ) : ( + + )}
- ) + ); } diff --git a/frontend/src/pages/settings/_account.tsx b/frontend/src/pages/settings/_account.tsx index 4c870075..a994337a 100644 --- a/frontend/src/pages/settings/_account.tsx +++ b/frontend/src/pages/settings/_account.tsx @@ -1,4 +1,10 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@radix-ui/react-dropdown-menu"; import { useDeleteOwnAccount } from "@/firebase-client/useDeleteOwnAccount"; @@ -18,19 +24,19 @@ import { AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, -} from "@/components/ui/alert-dialog" +} from "@/components/ui/alert-dialog"; import { useUpdateProfile } from "../../firebase-client/useUpdateProfile"; - export default function AccountSettingsCard() { const { user: currentUser } = useContext(AuthContext); const { deleteOwnAccount } = useDeleteOwnAccount(); const { updateUserProfile } = useUpdateProfile(); const saveButtonRef = useRef(null); const [showSuccess, setShowSuccess] = useState(false); - - console.log(currentUser); - const [updatedUser, setUpdatedUser] = useState({ uid: currentUser?.uid ?? '' } as EditableUser); + const { updateUser } = useUser(); + const [updatedUser, setUpdatedUser] = useState({ + uid: currentUser?.uid ?? "", + } as EditableUser); useEffect(() => { console.log(updatedUser); @@ -47,9 +53,15 @@ export default function AccountSettingsCard() { - setUpdatedUser((prev: any) => ({ ...prev, displayName: event.target.value } as EditableUser)) + setUpdatedUser( + (prev: any) => + ({ + ...prev, + displayName: event.target.value, + } as EditableUser) + ) } className="max-w-sm" /> @@ -58,39 +70,51 @@ export default function AccountSettingsCard() { - console.log(event.target.value) - } + onChange={(event) => console.log(event.target.value)} className="max-w-sm" />
- + updateUser(updatedUser); + updateUserProfile({ + displayName: updatedUser.displayName, + }).then(() => { + if (saveButtonRef.current) { + saveButtonRef.current.removeAttribute("disabled"); + } + setShowSuccess(true); + }); + }} + > + Save Changes + {showSuccess && ( - Successfully updated user profile! + + Successfully updated user profile! + )}
- +
Danger Zone - @@ -98,13 +122,16 @@ export default function AccountSettingsCard() { Are you absolutely sure? - This action cannot be undone. This will permanently delete your account - and remove your data from our servers. Please log in to GitHub again to continue. + This action cannot be undone. This will permanently delete + your account and remove your data from our servers. Please + log in to GitHub again to continue. Cancel - Delete Account + + Delete Account + @@ -112,5 +139,5 @@ export default function AccountSettingsCard() { - ) -} \ No newline at end of file + ); +} diff --git a/frontend/src/pages/settings/_match.tsx b/frontend/src/pages/settings/_match.tsx index a48fb2a8..0d4c794a 100644 --- a/frontend/src/pages/settings/_match.tsx +++ b/frontend/src/pages/settings/_match.tsx @@ -1,4 +1,10 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Label } from "@radix-ui/react-dropdown-menu"; import { useUser } from "@/hooks/useUser"; import { AuthContext } from "@/contexts/AuthContext"; @@ -6,11 +12,16 @@ import { useContext, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { EditableUser } from "@/types/UserTypes"; import DifficultySelector from "@/components/common/difficulty-selector"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Difficulty } from "@/types/QuestionTypes"; import { DotWave } from "@uiball/loaders"; - export default function MatchSettingsCard() { const { user: currentUser } = useContext(AuthContext); const { updateUser, getAppUser } = useUser(); @@ -18,9 +29,12 @@ export default function MatchSettingsCard() { const [showSuccess, setShowSuccess] = useState(false); const submitButtonRef = useRef(null); - const [updatedUser, setUpdatedUser] = useState({ uid: currentUser?.uid ?? '' } as EditableUser); - const [selectedDifficulty, setSelectedDifficulty] = useState('medium'); - const [selectedLanguage, setSelectedLanguage] = useState('c++'); + const [updatedUser, setUpdatedUser] = useState({ + uid: currentUser?.uid ?? "", + } as EditableUser); + const [selectedDifficulty, setSelectedDifficulty] = + useState("medium"); + const [selectedLanguage, setSelectedLanguage] = useState("c++"); useEffect(() => { if (currentUser) { @@ -32,15 +46,21 @@ export default function MatchSettingsCard() { setIsLoading(false); }); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentUser]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser]); useEffect(() => { - setUpdatedUser((prev) => ({ ...prev, matchDifficulty: selectedDifficulty })); + setUpdatedUser((prev) => ({ + ...prev, + matchDifficulty: selectedDifficulty, + })); }, [selectedDifficulty]); useEffect(() => { - setUpdatedUser((prev) => ({ ...prev, matchProgrammingLanguage: selectedLanguage })); + setUpdatedUser((prev) => ({ + ...prev, + matchProgrammingLanguage: selectedLanguage, + })); }, [selectedLanguage]); return ( @@ -51,54 +71,67 @@ export default function MatchSettingsCard() { {isLoading ? (
- +
) : ( - -
- - -
-
- - { - setShowSuccess(false); - setSelectedDifficulty(value); - }} /> -
-
- - {showSuccess && ( - Successfully updated match preferences! - )} -
-
+ +
+ + +
+
+ + { + setShowSuccess(false); + setSelectedDifficulty(value); + }} + /> +
+
+ + {showSuccess && ( + + Successfully updated match preferences! + + )} +
+
)} -
- ) -} \ No newline at end of file + ); +} From 977599cc36d1530d0bf6207f53120426a7cd52e7 Mon Sep 17 00:00:00 2001 From: "YIHSUEN\\Yi Hsuen" Date: Sat, 4 Nov 2023 13:00:00 +0800 Subject: [PATCH 36/50] Remove stackdump file --- .gitignore | 1 + bash.exe.stackdump | 44 -------------------------------------------- 2 files changed, 1 insertion(+), 44 deletions(-) delete mode 100644 bash.exe.stackdump diff --git a/.gitignore b/.gitignore index e0c43bb2..a6cf3575 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules .env.firebase_emulators_test secrets/ yarn-error.log +bash.exe.stackdump diff --git a/bash.exe.stackdump b/bash.exe.stackdump deleted file mode 100644 index 08ebf9cf..00000000 --- a/bash.exe.stackdump +++ /dev/null @@ -1,44 +0,0 @@ -Exception: STATUS_STACK_OVERFLOW at rip=0010040B625 -rax=0000000000000000 rbx=00000000FFE04040 rcx=0000000000000000 -rdx=0000000000000000 rsi=00000001004F73A0 rdi=00000000000000C8 -r8 =0000000000000000 r9 =00000000FFFFDE08 r10=00000000FFFFE458 -r11=0000000100000000 r12=0000000000000000 r13=00000008006429D0 -r14=0000000000000000 r15=00000000FFFFFFFF -rbp=0000000000000000 rsp=00000000FFE03E40 -program=C:\Program Files\Git\usr\bin\bash.exe, pid 2063, thread -cs=0033 ds=002B es=002B fs=0053 gs=002B ss=002B -Stack trace: -Frame Function Args -00000000000 0010040B625 (001004036F5, 00000000010, 00000000000, 000FFE04DB0) -00000000010 00100401FFA (00210199B0B, 00800082CE0, 00800642B60, 008006429D0) -00000000010 0010046ED5C (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -00800642810 00100417AD9 (00210199B0B, 00800082CE0, 00800642100, 00800642810) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -00800641DB0 00100417AD9 (00210199B0B, 00800082CE0, 008006416A0, 00800641DB0) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -00800641350 00100417AD9 (00210199B0B, 00800082CE0, 00800640C40, 00800641350) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -008006408F0 00100417AD9 (00210199B0B, 00800082CE0, 008006401E0, 008006408F0) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -0080063FE90 00100417AD9 (00210199B0B, 00800082CE0, 0080063F780, 0080063FE90) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -0080063F430 00100417AD9 (00210199B0B, 00800082CE0, 0080063ED20, 0080063F430) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -0080063E9D0 00100417AD9 (00210199B0B, 00800082CE0, 0080063E2C0, 0080063E9D0) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -0080063DF70 00100417AD9 (00210199B0B, 00800082CE0, 0080063D860, 0080063DF70) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -0080063D510 00100417AD9 (00210199B0B, 00800082CE0, 0080063CE00, 0080063D510) -00000000010 0010046F115 (001004FA2EA, 00000000415, 0021032C948, 00000000000) -00000000010 0010044A6AF (0010000001F, 00000000000, 00000000010, 001004FA2EA) -0080063CAB0 00100417AD9 (00210199B0B, 00800082CE0, 0080063C3A0, 0080063CAB0) -End of stack trace (more stack frames may be present) From 45d86a087ef051ca47856374f9b3b494b0fc5be7 Mon Sep 17 00:00:00 2001 From: Gabriel Goh <77230723+gycgabriel@users.noreply.github.com> Date: Sat, 4 Nov 2023 17:51:16 +0800 Subject: [PATCH 37/50] Question page Submit button (#228) Fix #226, fix #123 -- Grey out submit on click to prevent quick clicking -- Saves answer properly to attempt -- Implement Leave Room button /disconnect for collaboration room --- frontend/src/components/room/code-editor.tsx | 41 ++++++- frontend/src/components/room/description.tsx | 12 ++- frontend/src/hooks/useCollaboration.tsx | 8 +- frontend/src/pages/questions/[id]/index.tsx | 108 +++++++++++-------- frontend/src/pages/room/[id].tsx | 11 +- 5 files changed, 122 insertions(+), 58 deletions(-) diff --git a/frontend/src/components/room/code-editor.tsx b/frontend/src/components/room/code-editor.tsx index dac1ea5d..6c6e6739 100644 --- a/frontend/src/components/room/code-editor.tsx +++ b/frontend/src/components/room/code-editor.tsx @@ -37,6 +37,9 @@ type CodeEditorProps = { cursor?: number; onChange: React.Dispatch>; onCursorChange?: React.Dispatch>; + hasRoom?: boolean; + onSubmitClick?: (param: string) => void; + onLeaveRoomClick?: () => void; }; export const languages = [ @@ -64,9 +67,13 @@ export default function CodeEditor({ cursor, onChange, onCursorChange, + hasRoom = true, + onSubmitClick = () => {}, + onLeaveRoomClick = () => {}, }: CodeEditorProps) { const [open, setOpen] = React.useState(false); const [value, setValue] = React.useState(""); + const [isSubmitting, setIsSubmitting] = React.useState(false); const [monacoInstance, setMonacoInstance] = React.useState(null); @@ -108,6 +115,18 @@ export default function CodeEditor({ [onChange, onCursorChange, monacoInstance] ); + const handleOnSubmitClick = async () => { + if (isSubmitting) { + return; // Do nothing if a submission is already in progress. + } + setIsSubmitting(true); + try { + onSubmitClick(monacoInstance?.getValue() ?? value); + } catch (error) { + console.log(error); + } + }; + return (
@@ -178,11 +197,23 @@ export default function CodeEditor({ Console
- - + {/* */} + {hasRoom ? ( + + ) : ( + + )}
diff --git a/frontend/src/components/room/description.tsx b/frontend/src/components/room/description.tsx index 7bfb155d..f0c708fa 100644 --- a/frontend/src/components/room/description.tsx +++ b/frontend/src/components/room/description.tsx @@ -4,18 +4,18 @@ import { Button } from "../ui/button"; import { Card } from "../ui/card"; import { TypographyH2, TypographySmall } from "../ui/typography"; -// todo change this - type DescriptionProps = { question: Question; className?: string; onSwapQuestionClick?: () => void; + hasRoom?: boolean; }; export default function Description({ question, className, onSwapQuestionClick, + hasRoom = true, }: DescriptionProps) { return (
- + {hasRoom ? ( + + ) : null}
{question.topics.map((tag) => ( diff --git a/frontend/src/hooks/useCollaboration.tsx b/frontend/src/hooks/useCollaboration.tsx index 321135a0..6670c6a8 100644 --- a/frontend/src/hooks/useCollaboration.tsx +++ b/frontend/src/hooks/useCollaboration.tsx @@ -174,7 +174,13 @@ const useCollaboration = ({ }); }, [text, socket]); - return { text, setText, cursor, setCursor, room, setQuestionId }; + const disconnect = () => { + if (socket) { + socket.disconnect(); + } + }; + + return { text, setText, cursor, setCursor, room, setQuestionId, disconnect }; }; export default useCollaboration; diff --git a/frontend/src/pages/questions/[id]/index.tsx b/frontend/src/pages/questions/[id]/index.tsx index 9452c7ce..7a71421c 100644 --- a/frontend/src/pages/questions/[id]/index.tsx +++ b/frontend/src/pages/questions/[id]/index.tsx @@ -8,9 +8,11 @@ import { Question } from "../../../types/QuestionTypes"; import { AuthContext } from "@/contexts/AuthContext"; import { fetchQuestion } from "../../api/questionHandler"; import { MrMiyagi } from "@uiball/loaders"; +import { useHistory } from "@/hooks/useHistory"; export default function Questions() { const router = useRouter(); + const { postAttempt } = useHistory(); const questionId = router.query.id as string; const [question, setQuestion] = useState(null); const [loading, setLoading] = useState(true); // to be used later for loading states @@ -20,16 +22,19 @@ export default function Questions() { useEffect(() => { if (currentUser) { - fetchQuestion(currentUser, questionId).then(question => { - if (question) { - setQuestion(question); - } - }).catch(err => { - console.log(err); - router.push("/"); - }).finally(() => { - setLoading(false); - }); + fetchQuestion(currentUser, questionId) + .then((question) => { + if (question) { + setQuestion(question); + } + }) + .catch((err) => { + console.log(err); + router.push("/"); + }) + .finally(() => { + setLoading(false); + }); } else { // if user is not logged in, redirect to home router.push("/"); @@ -37,49 +42,60 @@ export default function Questions() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [questionId, authIsReady, currentUser]); - if (question === null && !loading) return (

Question not found

); + function onSubmitClick(value: string) { + postAttempt({ + uid: currentUser ? currentUser.uid : "user", + question_id: questionId, + answer: value || answer, + solved: true, // assume true + }) + .catch((err: any) => { + console.log(err); + }) + .finally(() => { + router.push("/profile"); + }); + } - // implement some on change solo save logic here - user side most likely + if (question === null && !loading) return

Question not found

; return (
- {!router.isReady || question === null || loading ? + {!router.isReady || question === null || loading ? (
- -
: - ( -
- - - - Description - - - Solution - - - - - - {question.solution} - -
- +
+ ) : ( +
+ + + + Description + + + Solution + + + + -
+ + {question.solution} + +
+
- )} +
+ )}
); } diff --git a/frontend/src/pages/room/[id].tsx b/frontend/src/pages/room/[id].tsx index aeba200e..4e614564 100644 --- a/frontend/src/pages/room/[id].tsx +++ b/frontend/src/pages/room/[id].tsx @@ -11,6 +11,7 @@ import { useQuestions } from "@/hooks/useQuestions"; import { useMatch } from "@/hooks/useMatch"; import { useEffect, useState } from "react"; import { MrMiyagi } from "@uiball/loaders"; +import { useMatchmaking } from "@/hooks/useMatchmaking"; export default function Room() { const router = useRouter(); @@ -19,7 +20,7 @@ export default function Room() { const disableVideo = (router.query.disableVideo as string)?.toLowerCase() === "true"; - const { text, setText, cursor, setCursor, room, setQuestionId } = + const { text, setText, cursor, setCursor, room, setQuestionId, disconnect } = useCollaboration({ roomId: roomId as string, userId, @@ -44,6 +45,7 @@ export default function Room() { const { fetchQuestion, fetchRandomQuestion } = useQuestions(); const { getMatch, updateQuestionIdInMatch } = useMatch(); + const { leaveMatch } = useMatchmaking(); const [match, setMatch] = useState(null); useEffect(() => { @@ -94,6 +96,12 @@ export default function Room() { } } + function onLeaveRoomClick(): void { + disconnect(); + leaveMatch(); + router.push("/"); + } + return (
{!router.isReady ? ( @@ -160,6 +168,7 @@ export default function Room() { cursor={cursor} onChange={setText} onCursorChange={setCursor} + onLeaveRoomClick={onLeaveRoomClick} />
From c10ce9a1fde363d0ef6eff7b8bdad1d6838e4ef5 Mon Sep 17 00:00:00 2001 From: Gabriel Goh <77230723+gycgabriel@users.noreply.github.com> Date: Sat, 4 Nov 2023 18:13:50 +0800 Subject: [PATCH 38/50] Preferred code language to default to first item --- frontend/src/pages/interviews/index.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/interviews/index.tsx b/frontend/src/pages/interviews/index.tsx index bce4c687..5a5b5c14 100644 --- a/frontend/src/pages/interviews/index.tsx +++ b/frontend/src/pages/interviews/index.tsx @@ -74,7 +74,9 @@ const leaderboardData = [ export default function Interviews() { const [open, setOpen] = useState(false); - const [value, setValue] = useState(""); + const [value, setValue] = useState( + languages.length > 0 ? languages[0].value : "" + ); const [difficulty, setDifficulty] = useState("medium"); const router = useRouter(); @@ -134,7 +136,13 @@ export default function Interviews() { - + 0 + ? languages[0].label + : "Search Language..." + } + /> No language found. {languages.map((language) => ( From 583c3b43b80ecb0dfa9e26d2adf5e3903c41dbd7 Mon Sep 17 00:00:00 2001 From: Tay Yi Hsuen Date: Wed, 8 Nov 2023 10:01:05 +0800 Subject: [PATCH 39/50] Add documentation for setting/removing admins (#218) --- services/admin-service/README.md | 42 ++++++++++++++++++++- services/admin-service/setAdminExample.png | Bin 0 -> 35298 bytes 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 services/admin-service/setAdminExample.png diff --git a/services/admin-service/README.md b/services/admin-service/README.md index 681f1b4f..6538b693 100644 --- a/services/admin-service/README.md +++ b/services/admin-service/README.md @@ -28,7 +28,47 @@ This will read in a file named `.env` for environment variables. Therefore, your FIREBASE_SERVICE_ACCOUNT ``` -This corresponds to the service account for the project on Firebase itself. +This corresponds to the service account for the project's development environment on Firebase itself. + +## How to add/remove admin rights for users on the application + +1. Run the command below. This will start up the admin service locally: +```shell +dotenv -e -c development -- yarn dev +``` + +Although the command is `dev`, the Firebase admin rights can also be added to/removed from the users on the production +or development version of the application. This is because the users and roles are stored remotely on Firebase. + +The application version affected depends on the value of +the `FIREBASE_SERVICE_ACCOUNT` environment variable in ``. + +Hence, if your `FIREBASE_SERVICE_ACCOUNT` environment variable has the service account for the production application, +the admin rights will be added to users registered in the production app. + +1. To set admin rights on a user, make a `PUT` request with `uid` corresponding to the user's uid in the Firebase project +corresponding to the application version. You can do this using a tool like [Postman](https://www.postman.com/), +which you need to install separately. + +``` +http://localhost:5005/api/admin-service/setAdmin/ +``` + +![Set Admin Example](./setAdminExample.png) + +1. To remove admin rights from the same user, make a `PUT` request with the same `uid`. +``` +http://localhost:5005/api/admin-service/removeAdmin/ +``` + +### Shortcut + +If you are setting/removing admin rights for users on the dev environment, you can simply run: +```shell +yarn workspace admin-service dev:local +``` + +This uses the default `.env` file which has a `FIREBASE_SERVICE_ACCOUNT` corresponding to the development app. ## How to do automated testing Automated testing is done using a [Firebase Local Emulator Suite](https://firebase.google.com/docs/emulator-suite). diff --git a/services/admin-service/setAdminExample.png b/services/admin-service/setAdminExample.png new file mode 100644 index 0000000000000000000000000000000000000000..1c116a9c2b3b27d90dc90cbb1fa98e042e59a9e9 GIT binary patch literal 35298 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 z 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 aagNJSPiMaep?@JX9%&jpD82vW)&Br`P8fs$ literal 0 HcmV?d00001 From cf27637c6e2aa52d239384cad6474864000e160d Mon Sep 17 00:00:00 2001 From: Tay Yi Hsuen Date: Wed, 8 Nov 2023 10:01:34 +0800 Subject: [PATCH 40/50] Add environment variables and remove unused code (#229) Changes: - Add environment variables at places where `localhost` is hard-coded. This is to support using Docker/Kubernetes addressing in the production environment. - Remove unused and commented out code - Refine Markdown documentation --- .gitignore | 1 + README.md | 39 ++++++++++++------- .../collaboration-service-deployment.yaml | 2 + .../matching-service-deployment.yaml | 4 ++ docker-compose.yml | 1 + services/README.md | 30 -------------- services/collaboration-service/README.md | 3 +- services/collaboration-service/src/app.ts | 2 +- services/collaboration-service/src/ot.ts | 18 --------- .../collaboration-service/src/routes/room.ts | 1 - services/gateway/README.md | 32 +++++++-------- services/gateway/src/auth/auth.ts | 1 - services/matching-service/src/app.ts | 2 +- .../src/controllers/matchingController.ts | 10 +---- .../matching-service/src/questionAdapter.ts | 2 +- services/user-service/README.md | 10 +++-- 16 files changed, 60 insertions(+), 98 deletions(-) delete mode 100644 services/README.md diff --git a/.gitignore b/.gitignore index a6cf3575..409ec9bf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules secrets/ yarn-error.log bash.exe.stackdump +sh.exe.stackdump diff --git a/README.md b/README.md index bb1fb6e8..262ebf23 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,19 @@ Prerequisites for PeerPrep Monorepo: -1. **Yarn:** Ensure you have the latest version of Yarn installed. Yarn +1. **Yarn:** Ensure you have the latest version of Yarn installed. Yarn Workspaces is available in Yarn v1.0 and later. -2. Installation (if not already installed): +2. Installation (if not already installed): ```bash npm install -g yarn ``` -3. **Node.js:** Check each application's documentation for the recommended +3. **Node.js:** Check each application's documentation for the recommended Node.js version. -4. **Git (Optional but Recommended):** -5. **Docker (If deploying with Docker):** -6. **Kubernetes Tools (If deploying with Kubernetes):** +4. **Git (Optional but Recommended):** +5. **Docker (If deploying with Docker):** +6. **Kubernetes Tools (If deploying with Kubernetes):** --- @@ -52,10 +52,19 @@ your services / frontend. MONGO_ATLAS_URL= FIREBASE_SERVICE_ACCOUNT= NEXT_PUBLIC_FRONTEND_FIREBASE_CONFIG={"apiKey": ,"authDomain": ,"projectId": ,"storageBucket": ,"messagingSenderId": ,"appId": } + TWILIO_ACCOUNT_SID= + TWILIO_API_KEY= + TWILIO_API_SECRET= ``` Note: For `NEXT_PUBLIC_FRONTEND_FIREBASE_CONFIG`, the JSON should not have newlines since Next.js may not process it correctly. +The difference between it and `FIREBASE_SERVICE_ACCOUNT` are shown below: -1. **Installing secret detection hooks:** From the root directory, run: +| Variable | Purpose | +| -------- | ------- | +| FIREBASE_SERVICE_ACCOUNT | For backend verification and administrative tasks | +| NEXT_PUBLIC_FRONTEND_FIREBASE_CONFIG | For the frontend to connect to Firebase | + +2. **Installing secret detection hooks:** From the root directory, run: ```bash pip install pre-commit pre-commit install @@ -66,7 +75,7 @@ As a tip, if you think a file will eventually store secrets, immediately add it it in case you forget later on when you have a lot more files to commit. -1. **Installing Dependencies:** From the root directory (`/peerprep`), run: +3. **Installing Dependencies:** From the root directory (`/peerprep`), run: ```bash yarn install @@ -83,27 +92,27 @@ it in case you forget later on when you have a lot more files to commit. This command will install dependencies for all services and the frontend in a centralized `node_modules` directory at the root. -1. **Adding Dependencies:** To add a dependency to a specific workspace (e.g., +4. **Adding Dependencies:** To add a dependency to a specific workspace (e.g., `user-service`), use: ```bash yarn workspace user-service add [dependency-name] ``` -1. **Initializing Prisma:** In the root file, run the following: +5. **Initializing Prisma:** In the root file, run the following: ```bash yarn prisma generate ## Do this whenever we change the models in schema.prisma ``` -1. **Running Backend Scripts:** To run a script specific to a workspace (e.g., +6. **Running Backend Scripts:** To run a script specific to a workspace (e.g., the `start` script for `user-service`), use: ```bash yarn workspace user-service start ``` -1. **Running Frontend Scripts:** To run the frontend cod, use: +7. **Running Frontend Scripts:** To run the frontend cod, use: ```bash yarn workspace frontend dev ## For development @@ -113,7 +122,7 @@ it in case you forget later on when you have a lot more files to commit. yarn workspace frontend build ## For first time setup run the build command yarn workspace frontend start ## For subsequent runs ``` -1. **Running everything at once:** To run everything at once and still maintain the ability to hot-reload your changes, use: +8. **Running everything at once:** To run everything at once and still maintain the ability to hot-reload your changes, use: ```bash ./start-app-no-docker.sh # on mac /linus @@ -134,13 +143,13 @@ yarn docker:build ``` This will create new Docker images. -1. **Run yarn docker:devup:** From the root repo, run +2. **Run yarn docker:devup:** From the root repo, run ```bash yarn docker:devup ``` This will start all the containers. -1. **Once done, run yarn docker:devdown:** From the root repo, run +3. **Once done, run yarn docker:devdown:** From the root repo, run ```bash yarn docker:devdown ``` diff --git a/deployment/gke-prod-manifests/collaboration-service-deployment.yaml b/deployment/gke-prod-manifests/collaboration-service-deployment.yaml index 692ee763..b3da11ca 100644 --- a/deployment/gke-prod-manifests/collaboration-service-deployment.yaml +++ b/deployment/gke-prod-manifests/collaboration-service-deployment.yaml @@ -19,6 +19,8 @@ spec: spec: containers: - env: + - name: FRONTEND_ADDRESS + value: "https://www.codeparty.org" - name: PRISMA_DATABASE_URL valueFrom: secretKeyRef: diff --git a/deployment/gke-prod-manifests/matching-service-deployment.yaml b/deployment/gke-prod-manifests/matching-service-deployment.yaml index 44471cdf..ff4afc6d 100644 --- a/deployment/gke-prod-manifests/matching-service-deployment.yaml +++ b/deployment/gke-prod-manifests/matching-service-deployment.yaml @@ -19,6 +19,10 @@ spec: spec: containers: - env: + - name: FRONTEND_ADDRESS + value: "https://www.codeparty.org" + - name: QUESTION_SERVICE_HOSTNAME + value: "question-service" - name: PRISMA_DATABASE_URL valueFrom: secretKeyRef: diff --git a/docker-compose.yml b/docker-compose.yml index 740914a5..0a9c5259 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: environment: PORT: 5002 PRISMA_DATABASE_URL: ${PRISMA_DATABASE_URL} + QUESTION_SERVICE_HOSTNAME: "question-service" collaboration-service: build: diff --git a/services/README.md b/services/README.md deleted file mode 100644 index 42fd1d4b..00000000 --- a/services/README.md +++ /dev/null @@ -1,30 +0,0 @@ - -# Installation - -Install Postgres 15.4 - - -Run these commands at each service folder root. -``` - install dependencies: - > yarn install --dev - - run the app: - > SET DEBUG=user-service:* & yarn run start -``` - -Open localhost:3000 on your browser to see the result. - - -# Database - -Local database for User Service (haven't figured how to make migration to save database state yet, help pls) - -user: dbuser -password: password -on localhost:5432 -(for now) - - - - diff --git a/services/collaboration-service/README.md b/services/collaboration-service/README.md index 2cd1725e..f6777371 100644 --- a/services/collaboration-service/README.md +++ b/services/collaboration-service/README.md @@ -28,7 +28,8 @@ To reconnect, simply join the same room again. ### Demo -To test out or see implementation example: See `demo.html`. +To test out or see implementation example: See `demo.html`. The steps below assume you are doing localhost development +and testing. Visit http://localhost:5003/demo/?room=1&user=user1 to set room and user. Open multiple tabs, and those with the same room will have same content. diff --git a/services/collaboration-service/src/app.ts b/services/collaboration-service/src/app.ts index f733c947..c4f2c6b8 100644 --- a/services/collaboration-service/src/app.ts +++ b/services/collaboration-service/src/app.ts @@ -14,7 +14,7 @@ const app: Express = express(); const server: HTTPServer = http.createServer(app); const socketIoOptions: any = { cors: { - origin: "http://localhost:3000", + origin: process.env.FRONTEND_ADDRESS || "http://localhost:3000", methods: ["GET", "POST"], }, }; diff --git a/services/collaboration-service/src/ot.ts b/services/collaboration-service/src/ot.ts index cacdb45f..00fba37c 100644 --- a/services/collaboration-service/src/ot.ts +++ b/services/collaboration-service/src/ot.ts @@ -180,20 +180,10 @@ export function transformPosition(cursor: number, op: TextOp): number { function test() { const text1 = "hello world"; - // console.log(type.apply(text1, remove(6, 1))); - // console.log( - // type.apply(type.apply(text1, remove(6, "w")), insert(9, "asdadasdk")) - // ); - // console.log( - // type.apply(text1, (remove(6, "w") as TextOp).concat(insert(3, "asdadasdk"))) - // ); const text2 = "good day hi everyone and the world"; const text3 = "good morning to the world and all who are in it"; const expected = "hi everyone good morning to the world and all who are in it"; /// or some gibberish similiar to this - // const textOp = createTextOpFromTexts(text1, text2); - // console.log(textOp); - // console.log(type.apply(text1, textOp)); const history_db = new OpHistoryMap(); @@ -220,14 +210,6 @@ function test() { console.log(newOp); console.log(type.apply(text2, newOp)); - // console.log( - // type.transform( - // (remove(0, "w") as TextOp).concat(insert(3, "asdadasdk")), - // (insert(1, "hello") as TextOp).concat(remove(3, "ak")), - // "left" - // ) - // ); - const newOp2 = type.transform(text1to2op, text1to3op, "right"); console.log(newOp2); console.log(type.apply(text3, newOp2)); diff --git a/services/collaboration-service/src/routes/room.ts b/services/collaboration-service/src/routes/room.ts index 015956a8..57f14346 100644 --- a/services/collaboration-service/src/routes/room.ts +++ b/services/collaboration-service/src/routes/room.ts @@ -2,7 +2,6 @@ import express, { Request, Response } from "express"; import { type } from "ot-text-unicode"; import { Socket, Server } from "socket.io"; -import { Room } from "@prisma/client"; import { createOrUpdateRoomWithUser, removeUserFromRoom, diff --git a/services/gateway/README.md b/services/gateway/README.md index e6cf6d05..cb3a461e 100644 --- a/services/gateway/README.md +++ b/services/gateway/README.md @@ -7,47 +7,47 @@ Much of the proxy functionality was adapted from [this tutorial](https://medium. The below code shows a sample route that is being proxied from the frontend to the backend through the gateway: ``` { - url: '/users', + url: "/api/user-service", admin_required_methods: [], // Empty, so no admin verification is done for all methods to the user-service user_match_required_methods: ["PUT", "DELETE"], + // PUT and DELETE require checking that the user is only updating/deleting their own data rateLimit: { windowMs: 15 * 60 * 1000, - max: 5 + max: 5, }, proxy: { - target: adminServiceAddress, + target: userServiceAddress, changeOrigin: true, - pathRewrite: { - [`^/users`]: '', - }, - } - } + }, +}, ``` This code is part of the `http_proxied_routes` list in `src/proxied_routes/proxied_routes.ts` file. Explanation: -* `url` - The initial path. Assuming that the gateway address is `YYY://localhost:4000`, the frontend would call `YYY://localhost:4000/users` +* `url` - The initial path. Assuming that the gateway address is `YYY://localhost:4000`, the frontend would call `YYY://localhost:4000/api/user-service` * `admin_required_methods` - a list of methods in which admin role is required to access the resource -* `user_match_required_methods` - a list of methods in which the `uid` in the URL path param must be checked against the current user in Firebase +* `user_match_required_methods` - a list of methods in which the `uid` in the `"User-Id"` header of the request must be checked against the current user in Firebase * `rateLimit` - currently unused. May be removed if not needed -* `proxy` - an object for routing the request to the user service. The underlying dependency used is `http-proxy-middleware` +* `proxy` - an object for routing the request to the user service. The underlying dependency used is [`http-proxy-middleware`](https://github.com/chimurai/http-proxy-middleware) ### Required headers The required headers are as follows: * `User-Id-Token` - the id token obtained by calling [`getIdToken()` on the current Firebase user](https://firebase.google.com/docs/reference/js/v8/firebase.User#getidtoken) +* `User-Id` - if user matching is done, the `uid` for which the request is being made to. Usually, requests requiring +the `uid` check will have the `uid` in the path param. So the `uid` value in `User-Id` and the path param must be the same. ## Required environment variables The Gateway requires the following environment variables: -| Environment variable file | File location | Environment Variable Name | Explanation | -|---------------------------| --- | --- |---------------------------------------------------------------------------------------------------------------------------| -| `.env` | Project root | `FIREBASE_SERVICE_ACCOUNT` | The service account corresponding to the app on Firebase. This is needed for API calls. | -| `.env.development` | Project root | `ENVIRONMENT_TYPE` | Set this to `local-dev` for `localhost` testing. In other environments like Docker and Kubernetes, this file is not read. | +| Environment variable file | File location | Environment Variable Name | Explanation | +|------------------------------------------------------| --- | --- |---------------------------------------------------------------------------------------------------------------------------| +| `.env` | Project root | `FIREBASE_SERVICE_ACCOUNT` | The service account corresponding to the app on Firebase. This is needed for API calls. | +| `.env.development.local` (already in source control) | Project root | `ENVIRONMENT_TYPE` | Set this to `local-dev` for `localhost` testing. In other environments like Docker and Kubernetes, this file is not read. | ## Local development and testing of the Gateway Steps: -1) Add an `.env` file at the project root with the above-mentioned variable as well as an `.env.development` file at the project root. +1) Add an `.env` file at the project root with the above-mentioned variable at the project root. 2) At the project root, run `yarn workspace gateway dev:local` diff --git a/services/gateway/src/auth/auth.ts b/services/gateway/src/auth/auth.ts index 0d7a93b5..74c8dedf 100644 --- a/services/gateway/src/auth/auth.ts +++ b/services/gateway/src/auth/auth.ts @@ -33,7 +33,6 @@ export const setupUserIdMatch = (app : Express, routes : any[]) => { routes.forEach(r => { app.use(r.url, function(req : express.Request, res : express.Response, next : express.NextFunction) { if (r.user_match_required_methods.includes(req.method)) { - console.log(req.params) const idToken = req.get(userIdTokenHeader); const paramUid = req.get(userIdHeader); if (!idToken || !paramUid) { diff --git a/services/matching-service/src/app.ts b/services/matching-service/src/app.ts index 2b86c3bc..c646529d 100644 --- a/services/matching-service/src/app.ts +++ b/services/matching-service/src/app.ts @@ -25,7 +25,7 @@ app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerFile)); const socketIoOptions: any = { cors: { - origin: "http://localhost:3000", + origin: process.env.FRONTEND_ADDRESS || "http://localhost:3000", methods: ["GET", "POST", "PATCH"], }, }; diff --git a/services/matching-service/src/controllers/matchingController.ts b/services/matching-service/src/controllers/matchingController.ts index 0151ca91..283d0f66 100644 --- a/services/matching-service/src/controllers/matchingController.ts +++ b/services/matching-service/src/controllers/matchingController.ts @@ -1,5 +1,5 @@ import { Request, Response } from "express"; -import { Server, Socket } from "socket.io"; +import { Socket } from "socket.io"; import { io } from "../app"; import prisma from "../prismaClient"; import EventEmitter from "events"; @@ -211,14 +211,6 @@ export function handleLooking( questionId: questionId, }, }), - // prisma.user.update({ - // where: { id: userId }, - // data: { matchedUserId: matchId }, - // }), - // prisma.user.update({ - // where: { id: matchId }, - // data: { matchedUserId: userId }, - // }), ]) .catch((err) => { console.log(err); diff --git a/services/matching-service/src/questionAdapter.ts b/services/matching-service/src/questionAdapter.ts index 226672a6..1bcd76a4 100644 --- a/services/matching-service/src/questionAdapter.ts +++ b/services/matching-service/src/questionAdapter.ts @@ -6,7 +6,7 @@ export async function getRandomQuestionOfDifficulty( const requestBody = JSON.stringify({ difficulty }); const options = { - hostname: "localhost", + hostname: process.env.QUESTION_SERVICE_HOSTNAME || "localhost", port: 5004, // Port of the question service path: "/api/question-service/random-question", method: "POST", diff --git a/services/user-service/README.md b/services/user-service/README.md index c1ef9025..55e28098 100644 --- a/services/user-service/README.md +++ b/services/user-service/README.md @@ -14,6 +14,8 @@ dotenv -e .env {insert the command here} ## How to run and develop locally: +Steps 1 and 2 are only compulsory if you are using a database on a locally-hosted Docker container: + 1) Start the database with the below command: ``` @@ -27,18 +29,18 @@ For the port mapping, `-p 5432:5432`, if you are running a local instance of Pos ``` The host port of 5431 is mapped to container port of 5432. Note that your host port must match the database URL you are using for your Prisma schema. -1) Access the database using: +2) Access the database using: ``` docker exec -it some-postgres psql -u {insert username here} -D {insert database name here} ``` -1) To start the user-service, from the root of the entire project, run the command: +3) To start the user-service, from the root of the entire project, run the command: ``` yarn workspace user-service dev:local ``` -1) The user-service will run on port 5001. You can test the API using Postman +4) The user-service will run on port 5001. You can test the API using Postman ## How to run automated tests: @@ -87,7 +89,7 @@ This also means that you need to pass in the environment variables to the CI wor #### Warning about system tests During system testing, a live database is used (although it only exists for the duration of the test). -In the current implementation of system test, the database is never cleared during, meaning that each test depends on the state of the previous test. +In the current implementation of system test, the database is never cleared during the entire testing process, meaning that each test depends on the state of the previous test. This also means that if you abort the system test (or it fails), re-running the system test is not guaranteed to succeed again after fixing the failure cause. From 0323b30695dcf24e84cebcb0da50bafada2cf26d Mon Sep 17 00:00:00 2001 From: Lee Chun Wei <47494777+chunweii@users.noreply.github.com> Date: Fri, 10 Nov 2023 23:36:24 +0800 Subject: [PATCH 41/50] Fix match backend (#230) Fixes #222 --- frontend/src/pages/api/userHandler.ts | 3 +- frontend/src/pages/interviews/index.tsx | 2 +- frontend/src/pages/interviews/match-found.tsx | 7 +- frontend/src/pages/profile/[id]/index.tsx | 9 +- frontend/src/pages/profile/_profile.tsx | 9 +- frontend/src/pages/settings/_match.tsx | 4 +- .../src/providers/MatchmakingProvider.tsx | 7 + .../migration.sql | 13 + prisma/schema.prisma | 14 +- services/matching-service/src/app.ts | 16 +- .../src/controllers/matchingController.ts | 420 +++++++----------- .../matching-service/src/questionAdapter.ts | 1 - .../matching-service/src/swagger-output.json | 76 ---- 13 files changed, 230 insertions(+), 351 deletions(-) create mode 100644 prisma/migrations/20231104152347_add_waiting_user_table/migration.sql diff --git a/frontend/src/pages/api/userHandler.ts b/frontend/src/pages/api/userHandler.ts index f811880d..c55765e5 100644 --- a/frontend/src/pages/api/userHandler.ts +++ b/frontend/src/pages/api/userHandler.ts @@ -1,5 +1,6 @@ import { userApiPathAddress } from "@/gateway-address/gateway-address"; import { EditableUser } from "@/types/UserTypes"; +import { AppUser } from "@prisma/client"; export const updateUserByUid = async (user: EditableUser, currentUser: any) => { try { @@ -34,7 +35,7 @@ export const updateUserByUid = async (user: EditableUser, currentUser: any) => { } }; -export const getUserByUid = async (uid: string, currentUser: any) => { +export const getUserByUid = async (uid: string, currentUser: any) : Promise => { try { const url = `${userApiPathAddress}${uid}`; const idToken = await currentUser.getIdToken(true); diff --git a/frontend/src/pages/interviews/index.tsx b/frontend/src/pages/interviews/index.tsx index 5a5b5c14..b6002d9a 100644 --- a/frontend/src/pages/interviews/index.tsx +++ b/frontend/src/pages/interviews/index.tsx @@ -86,7 +86,7 @@ export default function Interviews() { const onClickSearch = () => { try { - joinQueue([difficulty], value); // TODO: update with actual language + joinQueue(difficulty === "any" ? ["easy", "medium", "hard"] : [difficulty], value); console.log("Joined queue"); router.push(`/interviews/find-match`); } catch (error) { diff --git a/frontend/src/pages/interviews/match-found.tsx b/frontend/src/pages/interviews/match-found.tsx index 46ff2429..1b5b5e8c 100644 --- a/frontend/src/pages/interviews/match-found.tsx +++ b/frontend/src/pages/interviews/match-found.tsx @@ -38,7 +38,12 @@ export default function MatchFound() { match?.userId1 === user?.uid ? match?.userId2 : match?.userId1; const other = await getAppUser(otherUserId, false); - setOtherUser(other); + if (other) { + setOtherUser({ + displayName: other.displayName || "Anonymous", + photoUrl: other.photoUrl || defaultUser.photoUrl, + }); + } console.log(other); }; diff --git a/frontend/src/pages/profile/[id]/index.tsx b/frontend/src/pages/profile/[id]/index.tsx index c6fec8c3..20b2d856 100644 --- a/frontend/src/pages/profile/[id]/index.tsx +++ b/frontend/src/pages/profile/[id]/index.tsx @@ -1,22 +1,20 @@ -import Profile from "../_profile"; +import Profile, {UserProfile} from "../_profile"; import { useContext, useEffect, useState } from "react"; import { AuthContext } from "@/contexts/AuthContext"; import { Attempt } from "@/types/UserTypes"; import { useHistory } from "@/hooks/useHistory"; import { useRouter } from "next/router"; -import { User } from "firebase/auth"; import { useUser } from "@/hooks/useUser"; export default function Page() { const router = useRouter(); const id = router.query.id; - const { user: authUser, authIsReady } = useContext(AuthContext); const { getAppUser } = useUser(); const { user: currentUser } = useContext(AuthContext); const { fetchAttempts } = useHistory(); const [attempts, setAttempts] = useState([]); - const [user, setUser] = useState(); + const [user, setUser] = useState(); const [loadingState, setLoadingState] = useState< "loading" | "error" | "success" >("loading"); @@ -26,9 +24,8 @@ export default function Page() { Promise.all([getAppUser(id), fetchAttempts(id)]) .then(([user, attempts]) => { if (user && attempts) { - user["photoURL"] = user["photoUrl"]; console.log(user); - setUser(user); + setUser({...user, photoURL: user.photoUrl}); setAttempts(attempts); setLoadingState("success"); } else { diff --git a/frontend/src/pages/profile/_profile.tsx b/frontend/src/pages/profile/_profile.tsx index 7ed93154..5f8f0541 100644 --- a/frontend/src/pages/profile/_profile.tsx +++ b/frontend/src/pages/profile/_profile.tsx @@ -15,8 +15,15 @@ import { DataTable } from "@/components/profile/data-table"; import { columns } from "@/components/profile/columns"; import { DotWave } from "@uiball/loaders"; +export type UserProfile = { + uid: string; + displayName?: string | null; + photoURL?: string | null; + email?: string | null; +}; + type ProfileProps = { - selectedUser: User; + selectedUser: UserProfile; loadingState: "loading" | "error" | "success"; attempts?: Attempt[]; isCurrentUser: boolean; diff --git a/frontend/src/pages/settings/_match.tsx b/frontend/src/pages/settings/_match.tsx index 0d4c794a..3aae99bb 100644 --- a/frontend/src/pages/settings/_match.tsx +++ b/frontend/src/pages/settings/_match.tsx @@ -40,8 +40,8 @@ export default function MatchSettingsCard() { if (currentUser) { getAppUser().then((user) => { if (user) { - setSelectedDifficulty(user.matchDifficulty); - setSelectedLanguage(user.matchProgrammingLanguage); + setSelectedDifficulty(user.matchDifficulty as Difficulty || selectedDifficulty); + setSelectedLanguage(user.matchProgrammingLanguage || selectedLanguage); } setIsLoading(false); }); diff --git a/frontend/src/providers/MatchmakingProvider.tsx b/frontend/src/providers/MatchmakingProvider.tsx index b58ccef9..dcfbd512 100644 --- a/frontend/src/providers/MatchmakingProvider.tsx +++ b/frontend/src/providers/MatchmakingProvider.tsx @@ -80,9 +80,15 @@ export const MatchmakingProvider: React.FC = ({ socket.on("matchFound", (match: Match) => { console.log("Match found:", match); console.log("QuestionId:", match.questionId); + socket.emit("joinRoom", match.roomId); setMatch(match); }); + socket.on("matchLeft", (match: Match) => { + console.log("Match left:", match); + setMatch(null); + }) + socket.on("receiveMessage", (message: string) => { console.log("Message received:", message); setMessage(message); @@ -100,6 +106,7 @@ export const MatchmakingProvider: React.FC = ({ return () => { socket.off("connect"); socket.off("matchFound"); + socket.off("matchLeft"); socket.off("receiveMessage"); socket.off("error"); socket.off("disconnect"); diff --git a/prisma/migrations/20231104152347_add_waiting_user_table/migration.sql b/prisma/migrations/20231104152347_add_waiting_user_table/migration.sql new file mode 100644 index 00000000..055e7bd8 --- /dev/null +++ b/prisma/migrations/20231104152347_add_waiting_user_table/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "WaitingUser" ( + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "progLang" TEXT NOT NULL, + "difficulty" TEXT[], + "socketId" TEXT NOT NULL, + + CONSTRAINT "WaitingUser_pkey" PRIMARY KEY ("userId") +); + +-- AddForeignKey +ALTER TABLE "WaitingUser" ADD CONSTRAINT "WaitingUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "AppUser"("uid") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c082575..f641809b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,6 +14,15 @@ model User { lastConnected DateTime? } +model WaitingUser { + userId String @id + user AppUser @relation(fields: [userId], references: [uid]) + createdAt DateTime @default(now()) + progLang String + difficulty String[] + socketId String +} + model Match { roomId String @id @default(uuid()) userId1 String @@ -25,12 +34,13 @@ model Match { } model AppUser { - uid String @id + uid String @id displayName String? photoUrl String? matchDifficulty String? matchProgrammingLanguage String? - attempts Attempt[] @relation("AppUserToAttempt") + attempts Attempt[] @relation("AppUserToAttempt") + WaitingUser WaitingUser[] } model Room { diff --git a/services/matching-service/src/app.ts b/services/matching-service/src/app.ts index c646529d..8aac8869 100644 --- a/services/matching-service/src/app.ts +++ b/services/matching-service/src/app.ts @@ -5,6 +5,7 @@ import matchingRoutes from "./routes/matchingRoutes"; import { handleConnection, handleDisconnect, + handleJoinRoom, handleLooking, } from "./controllers/matchingController"; import { handleCancelLooking } from "./controllers/matchingController"; @@ -35,28 +36,27 @@ export const io = new Server(httpServer, socketIoOptions); app.set("io", io); -io.on("connection", (socket) => { - let { userId, userMatchReq, timer } = handleConnection(socket); +io.on("connection", async (socket) => { + let userId = await handleConnection(socket); socket.on( "disconnect", - handleDisconnect(socket, timer, userId, userMatchReq) + handleDisconnect(socket, userId) ); socket.on( "lookingForMatch", - handleLooking(socket, userId, userMatchReq, timer) + handleLooking(socket, userId) ); - socket.on("cancelLooking", handleCancelLooking(userId, timer, userMatchReq)); + socket.on("cancelLooking", handleCancelLooking(userId)); socket.on("leaveMatch", handleLeaveMatch(userId, socket)); socket.on("sendMessage", handleSendMessage(userId, socket)); - socket.on("matchFound", async (userId: string, matchedUserId: string) => { - // todo - in the FE handle this - }); + socket.on("joinRoom", handleJoinRoom(userId, socket)); + }); httpServer.listen(port, () => { diff --git a/services/matching-service/src/controllers/matchingController.ts b/services/matching-service/src/controllers/matchingController.ts index 283d0f66..bef4c171 100644 --- a/services/matching-service/src/controllers/matchingController.ts +++ b/services/matching-service/src/controllers/matchingController.ts @@ -2,155 +2,106 @@ import { Request, Response } from "express"; import { Socket } from "socket.io"; import { io } from "../app"; import prisma from "../prismaClient"; -import EventEmitter from "events"; -import { Match } from "@prisma/client"; import { getRandomQuestionOfDifficulty } from "../questionAdapter"; +import { EnumRoomStatus } from "@prisma/client"; export const MAX_WAITING_TIME = 60 * 1000; // 60 seconds -export type UserMatchReq = { - userId: string; - difficulties: string[]; - programmingLang: string; -}; - -export const userQueuesByProgrammingLanguage: { - [language: string]: UserMatchReq[]; -} = { - python: [], - java: [], - "c++": [], -}; - -export const waitingUsers: Map = new Map(); // key: user id, val: Event - -export function handleConnection(socket: Socket) { +export async function handleConnection(socket: Socket) { let userId = (socket.handshake.query.username as string) || ""; console.log(`User connected: ${socket.id} and username ${userId}`); - let userMatchReq: UserMatchReq = { - userId: userId, - difficulties: [], - programmingLang: "python", - }; + const { count: earlierWaitingCount } = await prisma.waitingUser.deleteMany({ + where: { + userId: userId, + }, + }); - if (waitingUsers.has(userId)) { - console.log(`User ${userId} is waiting in the queue in another session`); + if (earlierWaitingCount >= 1) { + console.log(`User ${userId} was waiting in the queue in another session`); socket.emit( "error", - "You are already waiting in the queue in another session." + "You are already waiting in the queue in another session. You will be removed from the queue." ); - socket.disconnect(); - } else { - prisma.match - .findFirst({ - where: { - OR: [{ userId1: userId }, { userId2: userId }], - }, - }) - .then((existingMatch) => { - if (existingMatch) { - console.log( - `User ${userId} is already matched with user ${ - existingMatch.userId1 === userId - ? existingMatch.userId2 - : existingMatch.userId1 - }` - ); - socket.emit("error", "You are already matched with someone."); - socket.join(existingMatch.roomId); - socket.emit("matchFound", existingMatch); - } - }) - .catch((err) => { - console.log(err); - socket.emit("error", "An error occurred."); - }); } - let timer = setTimeout(() => {}, 0); - return { userId, userMatchReq, timer }; + // Join the room if the user is in a match + const existingMatch = await prisma.match.findFirst({ + where: { + OR: [{ userId1: userId }, { userId2: userId }], + }, + }); + + if (existingMatch) { + socket.join(existingMatch.roomId); + socket.emit("matchFound", existingMatch); + } + + return userId; } export function handleDisconnect( socket: Socket, - // eslint-disable-next-line no-undef - timer: NodeJS.Timeout, - userId: string, - userMatchReq: UserMatchReq + userId: string ) { return () => { console.log(`User disconnected: ${socket.id}`); // Remove user from queue if they disconnect - clearTimeout(timer); - if (waitingUsers.has(userId)) { - console.log(`User ${userId} disconnected while waiting for a match`); - userQueuesByProgrammingLanguage[userMatchReq.programmingLang] = - userQueuesByProgrammingLanguage[userMatchReq.programmingLang]?.filter( - (user) => user.userId !== userId - ); - waitingUsers.get(userId)?.removeAllListeners(); - waitingUsers.delete(userId); - } + prisma.waitingUser.deleteMany({ + where: { + userId: userId, + } + }).catch((err) => { + console.log(err); + }); + // Match should not be cancelled since the user might reconnect but we can notify the other user - prisma.match - .findFirst({ + prisma.match.findFirst({ where: { OR: [{ userId1: userId }, { userId2: userId }], }, - }) - .then((match) => { - if (match) { - const matchingUserId = - match?.userId1 === userId ? match?.userId2 : match?.userId1; - console.log( - `Notifying user ${matchingUserId} that user ${userId} has disconnected` - ); - io.to(match?.roomId || "").emit( - "receiveMessage", - "Server", - "Your partner has disconnected" - ); - } - }) - .catch((err) => { - console.log(err); - socket.emit("error", "An error occurred."); - }); + }).then((match) => { + if (match) { + const matchingUserId = + match?.userId1 === userId ? match?.userId2 : match?.userId1; + console.log( + `Notifying user ${matchingUserId} that user ${userId} has disconnected` + ); + io.to(match?.roomId || "").emit( + "receiveMessage", + "Server", + "Your partner has disconnected" + ); + } + }).catch((err) => { + console.log(err); + }); + + }; } export function handleLooking( socket: Socket, userId: string, - userMatchReq: UserMatchReq, - // eslint-disable-next-line no-undef - timer: NodeJS.Timeout -): (...args: any[]) => void { +): (difficulties: string[], programmingLang: string) => Promise { return async (difficulties: string[], programmingLang: string) => { if (!difficulties || !programmingLang) { console.log(`Invalid request from user ${userId}`); socket.emit("error", "Invalid request"); return; } - if (waitingUsers.has(userId)) { - console.log(`User ${userId} is already in the queue`); - socket.emit("error", "You are already in the queue."); - return; - } let hasError = false; - const existingMatch = await prisma.match - .findFirst({ - where: { - OR: [{ userId1: userId }, { userId2: userId }], - }, - }) - .catch((err) => { - console.log(err); - socket.emit("error", "An error occurred in lookingForMatch."); - hasError = true; - }); + const existingMatch = await prisma.match.findFirst({ + where: { + OR: [{ userId1: userId }, { userId2: userId }], + }, + }).catch((err) => { + console.log(err); + socket.emit("error", "An error occurred in lookingForMatch."); + hasError = true; + }); if (hasError) { return; @@ -170,171 +121,136 @@ export function handleLooking( return; } - userMatchReq.difficulties = difficulties; - userMatchReq.programmingLang = programmingLang; - - console.log( - `User ${userId} is looking for a match with difficulties ${difficulties} and programming language ${programmingLang}` - ); - - // Attempt to find a match for the user - const matchedUser = userQueuesByProgrammingLanguage[programmingLang]?.find( - (userMatchReq) => - userId !== userMatchReq.userId && - userMatchReq.difficulties.find((v) => difficulties.includes(v)) - ); - const matchId = matchedUser?.userId; - const difficulty = matchedUser?.difficulties.find((v) => - difficulties.includes(v) - ); - - if (matchId) { - console.log( - `Match found for user ${userId} with user ${matchId} and difficulty ${difficulty}` - ); - - const questionId = await getRandomQuestionOfDifficulty( - difficulty! ?? "easy" - ).then((questionId) => { - return questionId; + let {newMatch: foundMatch, matchingUser} = await prisma.$transaction(async (tx) => { + const matchingUser = await tx.waitingUser.findFirst({ + where: { + progLang: programmingLang, + difficulty: { + hasSome: difficulties, + }, + createdAt: { + gte: new Date(Date.now() - MAX_WAITING_TIME), + } + }, }); - - // Inform both users of the match - const newMatch = await prisma - .$transaction([ - prisma.match.create({ - data: { - userId1: userId, - userId2: matchId, - chosenDifficulty: difficulty || "easy", - chosenProgrammingLanguage: programmingLang, - questionId: questionId, + if (matchingUser) { + const commonDifficulty = matchingUser.difficulty.find((v) => difficulties.includes(v)); + const newMatch = await tx.match.create({ + data: { + userId1: matchingUser.userId, + userId2: userId, + chosenDifficulty: commonDifficulty || "easy", + chosenProgrammingLanguage: programmingLang, + }, + }); + await tx.room.create({ + data: { + room_id: newMatch.roomId, + status: EnumRoomStatus.active, + text: "" + }, + }); + await tx.waitingUser.deleteMany({ + where: { + userId: { + in: [matchingUser.userId, userId], }, - }), - ]) - .catch((err) => { - console.log(err); - socket.emit("error", "An error occurred in lookingForMatch."); - hasError = true; - }) - .then((res) => { - return res && res[0]; + }, }); - if (hasError || !newMatch) { - return; + return {newMatch, matchingUser}; + } else { + await tx.waitingUser.create({ + data: { + userId: userId, + progLang: programmingLang, + difficulty: difficulties, + socketId: socket.id, + }, + }); + return { + newMatch: null, + matchingUser: null, + }; } - waitingUsers.get(matchId)?.emit("matchFound", newMatch); - socket.emit("matchFound", newMatch); - socket.join(newMatch.roomId); - // Remove both users from the queue - userQueuesByProgrammingLanguage[programmingLang] = - userQueuesByProgrammingLanguage[programmingLang].filter( - (user) => user.userId !== matchId && user.userId !== userId - ); - waitingUsers.delete(matchId); - waitingUsers.delete(userId); - } else { - // Add user to the queue - userQueuesByProgrammingLanguage[programmingLang] = - userQueuesByProgrammingLanguage[programmingLang] || []; - userQueuesByProgrammingLanguage[programmingLang].push({ - userId: userId, - difficulties, - programmingLang, - }); - let event = new EventEmitter(); - waitingUsers.set(userId, event); - event.on("matchFound", (match: Match) => { - console.log( - `Match found for user ${userId} with user ${ - match.userId1 === userId ? match.userId2 : match.userId1 - } and difficulty ${match.chosenDifficulty}` - ); - socket.join(match.roomId); - socket.emit("matchFound", match); - clearTimeout(timer); - }); - timer = setTimeout(() => { - if (waitingUsers.has(userId)) { - console.log(`No match found for user ${userId} yet.`); - userQueuesByProgrammingLanguage[programmingLang] = - userQueuesByProgrammingLanguage[programmingLang].filter( - (user) => user.userId !== userId - ); - waitingUsers.delete(userId); - socket.emit("matchNotFound"); - } - }, MAX_WAITING_TIME); - console.log(`Queueing user ${userId}.`); + }); + + if (!foundMatch) { + console.log(`Queued user ${userId}.`); + return; } + + const qnId = await getRandomQuestionOfDifficulty(foundMatch.chosenDifficulty); + foundMatch = await prisma.match.update({ + where: { + roomId: foundMatch.roomId, + }, + data: { + questionId: qnId, + }, + }); + + console.log( + `Match found for user ${userId} with user ${ + foundMatch.userId1 === userId ? foundMatch.userId2 : foundMatch.userId1 + } and difficulty ${foundMatch.chosenDifficulty}` + ); + + // Inform both users of the match + socket.emit("matchFound", foundMatch); + io.to(matchingUser?.socketId || "").emit("matchFound", foundMatch); + }; } + export function handleCancelLooking( - userId: string, - // eslint-disable-next-line no-undef - timer: NodeJS.Timeout, - userMatchReq: UserMatchReq -): (...args: any[]) => void { + userId: string +): () => Promise { return async () => { console.log(`User ${userId} is no longer looking for a match`); - clearTimeout(timer); - userQueuesByProgrammingLanguage[userMatchReq.programmingLang] = - userQueuesByProgrammingLanguage[userMatchReq.programmingLang].filter( - (user) => user.userId !== userId - ); - waitingUsers.delete(userId); + await prisma.waitingUser.deleteMany({ + where: { + userId: userId, + }, + }); + }; +} + +export function handleJoinRoom(userId: string, socket: Socket): (roomId: string) => void { + return (roomId: string) => { + // TODO: Check if the user is in a match with relevant room id + console.log(`User ${socket.id} is joining room ${roomId}`); + socket.join(roomId); }; } export function handleLeaveMatch( userId: string, socket: Socket -): (...args: any[]) => void { +): () => Promise { return async () => { console.log(`User ${userId} has left the match`); - const match = await prisma.match - .findFirst({ + const deletedRoom = await prisma.$transaction(async (tx) => { + const match = await tx.match.findFirst({ where: { OR: [{ userId1: userId }, { userId2: userId }], }, - }) - .catch((err) => { - console.log(err); - socket.emit("error", "An error occurred in leaveMatch."); }); + if (!match) { + console.log(`User ${userId} is not currently matched with anyone.`); + socket.emit("error", "You are not currently matched with anyone."); + return; + } + return await tx.match.delete({ + where: { + roomId: match?.roomId, + }, + }); + }); - if (match) { - // Notify the matched user - const matchingUserId = - match?.userId1 === userId ? match?.userId2 : match?.userId1; - console.log( - `Notifying user ${matchingUserId} that user ${userId} has left the match` - ); - io.to(match.roomId).emit("matchLeft", match); - - await prisma - .$transaction([ - prisma.user.update({ - where: { id: userId }, - data: { matchedUserId: null }, - }), - prisma.user.update({ - where: { - id: match.userId1 === userId ? match.userId2 : match.userId1, - }, - data: { matchedUserId: null }, - }), - prisma.match.delete({ - where: { - roomId: match?.roomId, - }, - }), - ]) - .catch((err) => { - console.log(err); - socket.emit("error", "An error occurred in leaveMatch."); - }); + if (deletedRoom) { + console.log(`Room ${deletedRoom} has been deleted`); + io.to(deletedRoom.roomId).emit("matchLeft", deletedRoom); } }; } @@ -342,7 +258,7 @@ export function handleLeaveMatch( export function handleSendMessage( userId: string, socket: Socket -): (...args: any[]) => void { +): (message: string) => Promise { return async (message: string) => { if (!userId || !message) { console.log(`Invalid request from user ${userId}`); diff --git a/services/matching-service/src/questionAdapter.ts b/services/matching-service/src/questionAdapter.ts index 1bcd76a4..22a15746 100644 --- a/services/matching-service/src/questionAdapter.ts +++ b/services/matching-service/src/questionAdapter.ts @@ -27,7 +27,6 @@ export async function getRandomQuestionOfDifficulty( response.on("end", () => { try { const parsedData = JSON.parse(data); - console.log(parsedData); const qnId = parsedData[0]._id; if (qnId) { resolve(qnId); diff --git a/services/matching-service/src/swagger-output.json b/services/matching-service/src/swagger-output.json index b5d54982..f2a5e202 100644 --- a/services/matching-service/src/swagger-output.json +++ b/services/matching-service/src/swagger-output.json @@ -11,83 +11,7 @@ } ], "paths": { - "/api/matching-service/{userId}/findMatch": { - "get": { - "description": "", - "parameters": [ - { - "name": "userId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "202": { - "description": "Accepted" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/matching-service/{userId}/leave": { - "post": { - "description": "", - "parameters": [ - { - "name": "userId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - } - }, "/api/matching-service/match/{room_id}": { - "get": { - "description": "", - "parameters": [ - { - "name": "room_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - }, "patch": { "description": "", "parameters": [ From 3718b16cf2685cdc98dc7d0bb3626fe5f80d9398 Mon Sep 17 00:00:00 2001 From: Ong Jun Xiong Date: Sat, 11 Nov 2023 01:37:16 +0800 Subject: [PATCH 42/50] add preference update to interviews page --- .../components/common/difficulty-selector.tsx | 47 ++++++++++--------- frontend/src/components/ui/button.tsx | 25 +++++----- frontend/src/pages/interviews/index.tsx | 47 ++++++++++++++----- 3 files changed, 74 insertions(+), 45 deletions(-) diff --git a/frontend/src/components/common/difficulty-selector.tsx b/frontend/src/components/common/difficulty-selector.tsx index 263d3460..cf8e426b 100644 --- a/frontend/src/components/common/difficulty-selector.tsx +++ b/frontend/src/components/common/difficulty-selector.tsx @@ -1,15 +1,21 @@ +import Loader from "../interviews/loader"; import { Button } from "../ui/button"; -type Difficulty = 'easy' | 'medium' | 'hard' | 'any'; +type Difficulty = "easy" | "medium" | "hard" | "any"; interface DifficultySelectorProps { onChange: (value: Difficulty) => void; showAny: boolean; value: Difficulty; + isLoading: boolean; } -export default function DifficultySelector({ onChange, showAny, value }: DifficultySelectorProps) { - +export default function DifficultySelector({ + onChange, + showAny, + value, + isLoading = false, +}: DifficultySelectorProps) { const difficulties = [ { label: "Easy", value: "easy" }, { label: "Medium", value: "medium" }, @@ -22,23 +28,22 @@ export default function DifficultySelector({ onChange, showAny, value }: Difficu return (
- { - difficulties.map((difficulty) => ( - - )) - } + {difficulties.map((difficulty) => ( + + ))}
- ) + ); } diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 18ecbae5..63f91ef5 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center rounded-md text-base font-semibold ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-30", @@ -14,8 +14,7 @@ const buttonVariants = cva( "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: "border-2 border-primary bg-transparent hover:bg-accent text-primary", - secondary: - "bg-accent text-accent-foreground hover:bg-accent/50", + secondary: "bg-accent text-accent-foreground hover:bg-accent/50", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, @@ -31,26 +30,26 @@ const buttonVariants = cva( size: "default", }, } -) +); export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { - asChild?: boolean + asChild?: boolean; } const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" + const Comp = asChild ? Slot : "button"; return ( - ) + ); } -) -Button.displayName = "Button" +); +Button.displayName = "Button"; -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/frontend/src/pages/interviews/index.tsx b/frontend/src/pages/interviews/index.tsx index b6002d9a..0f8b9059 100644 --- a/frontend/src/pages/interviews/index.tsx +++ b/frontend/src/pages/interviews/index.tsx @@ -21,11 +21,13 @@ import { TypographyH2, TypographySmall, } from "@/components/ui/typography"; +import { AuthContext } from "@/contexts/AuthContext"; import { useMatchmaking } from "@/hooks/useMatchmaking"; +import { useUser } from "@/hooks/useUser"; import { cn } from "@/lib/utils"; import { Check, ChevronsUpDown } from "lucide-react"; import { useRouter } from "next/router"; -import { useState } from "react"; +import { useContext, useEffect, useState } from "react"; type Difficulty = "easy" | "medium" | "hard" | "any"; @@ -73,20 +75,39 @@ const leaderboardData = [ ]; export default function Interviews() { + const { user: currentUser } = useContext(AuthContext); const [open, setOpen] = useState(false); - const [value, setValue] = useState( - languages.length > 0 ? languages[0].value : "" + const [selectedLanguage, setSelectedLanguage] = useState( + languages.length > 0 ? languages[0].value : "c++" ); const [difficulty, setDifficulty] = useState("medium"); const router = useRouter(); const { joinQueue } = useMatchmaking(); + const { getAppUser } = useUser(); + const [isLoading, setIsLoading] = useState(true); - // const { id } = router.query; + useEffect(() => { + if (currentUser) { + getAppUser().then((user) => { + if (user) { + setDifficulty((user.matchDifficulty as Difficulty) || difficulty); + setSelectedLanguage( + user.matchProgrammingLanguage || selectedLanguage + ); + } + setIsLoading(false); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser]); const onClickSearch = () => { try { - joinQueue(difficulty === "any" ? ["easy", "medium", "hard"] : [difficulty], value); + joinQueue( + difficulty === "any" ? ["easy", "medium", "hard"] : [difficulty], + selectedLanguage + ); console.log("Joined queue"); router.push(`/interviews/find-match`); } catch (error) { @@ -113,6 +134,7 @@ export default function Interviews() { onChange={(value) => setDifficulty(value)} showAny={true} value={difficulty} + isLoading={isLoading} /> @@ -127,9 +149,10 @@ export default function Interviews() { aria-expanded={open} className="w-[240px] justify-between" > - {value - ? languages.find((language) => language.value === value) - ?.label + {selectedLanguage + ? languages.find( + (language) => language.value === selectedLanguage + )?.label : "Select Language..."} @@ -149,8 +172,10 @@ export default function Interviews() { { - setValue( - currentValue === value ? "" : currentValue + setSelectedLanguage( + currentValue === selectedLanguage + ? "" + : currentValue ); setOpen(false); }} @@ -158,7 +183,7 @@ export default function Interviews() { Date: Sat, 11 Nov 2023 02:35:52 +0800 Subject: [PATCH 43/50] link up matching finding and added more error states --- .../interviews/leaderboard/columns.tsx | 36 ++-- frontend/src/pages/_app.tsx | 14 ++ frontend/src/pages/interviews/find-match.tsx | 13 +- frontend/src/pages/interviews/index.tsx | 2 + frontend/src/pages/interviews/match-found.tsx | 67 ++++-- frontend/src/pages/room/[id].tsx | 6 +- package.json | 1 + .../src/controllers/matchingController.ts | 195 +++++++++--------- yarn.lock | 12 ++ 9 files changed, 207 insertions(+), 139 deletions(-) diff --git a/frontend/src/components/interviews/leaderboard/columns.tsx b/frontend/src/components/interviews/leaderboard/columns.tsx index 6674509a..0f8d7b74 100644 --- a/frontend/src/components/interviews/leaderboard/columns.tsx +++ b/frontend/src/components/interviews/leaderboard/columns.tsx @@ -1,13 +1,13 @@ -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; -import { ColumnDef } from "@tanstack/react-table" +import { ColumnDef } from "@tanstack/react-table"; export type PublicUser = { - displayName: string - attempts: number - photoURL: string -} + displayName: string; + attempts: number; + photoURL: string; +}; const getInitials = (name: string) => { const names = name.split(" "); @@ -16,32 +16,33 @@ const getInitials = (name: string) => { initials += n[0].toUpperCase(); }); return initials; -} +}; export const columns: ColumnDef[] = [ { accessorKey: "displayName", header: "User", cell: ({ row }) => { - const displayName = row.getValue("displayName") as string - const photoURL = row.original.photoURL + const displayName = row.getValue("displayName") as string; + const photoURL = row.original.photoURL; return ( - - ) + ); }, }, { @@ -49,5 +50,4 @@ export const columns: ColumnDef[] = [ header: "Solved", invertSorting: true, }, -] - +]; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index f8f4808a..46951e52 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -5,6 +5,8 @@ import { Noto_Sans } from "next/font/google"; import AuthContextProvider from "@/contexts/AuthContext"; import { MatchmakingProvider } from "../providers/MatchmakingProvider"; import AuthChecker from "@/components/common/auth-checker"; +import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; const notoSans = Noto_Sans({ weight: ["400", "500", "600", "700", "800", "900"], @@ -23,6 +25,18 @@ export default function App({ Component, pageProps }: AppProps) { + diff --git a/frontend/src/pages/interviews/find-match.tsx b/frontend/src/pages/interviews/find-match.tsx index dbb915c2..e2d6428f 100644 --- a/frontend/src/pages/interviews/find-match.tsx +++ b/frontend/src/pages/interviews/find-match.tsx @@ -8,12 +8,20 @@ import { useMatchmaking } from "@/hooks/useMatchmaking"; export default function FindMatch() { const router = useRouter(); const { match, cancelLooking } = useMatchmaking(); + const { query } = router; + const { retry } = query; const onClickCancel = () => { cancelLooking(); router.push("/interviews"); }; + useEffect(() => { + if (retry) { + router.push("/interviews"); + } + }, [retry, router]); + useEffect(() => { let timeout: ReturnType | null = null; if (match) { @@ -25,9 +33,8 @@ export default function FindMatch() { }, 30000); } return () => { - if (timeout) - clearTimeout(timeout); - } + if (timeout) clearTimeout(timeout); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [match, router]); diff --git a/frontend/src/pages/interviews/index.tsx b/frontend/src/pages/interviews/index.tsx index 0f8b9059..5ea7b28f 100644 --- a/frontend/src/pages/interviews/index.tsx +++ b/frontend/src/pages/interviews/index.tsx @@ -102,6 +102,8 @@ export default function Interviews() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentUser]); + // sync leaderboard data + const onClickSearch = () => { try { joinQueue( diff --git a/frontend/src/pages/interviews/match-found.tsx b/frontend/src/pages/interviews/match-found.tsx index 1b5b5e8c..bc2bdda9 100644 --- a/frontend/src/pages/interviews/match-found.tsx +++ b/frontend/src/pages/interviews/match-found.tsx @@ -1,3 +1,4 @@ +import { languages } from "@/components/room/code-editor"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -9,10 +10,12 @@ import { import { AuthContext } from "@/contexts/AuthContext"; import { useMatchmaking } from "@/hooks/useMatchmaking"; import { useUser } from "@/hooks/useUser"; +import { Difficulty } from "@/types/QuestionTypes"; import { query } from "express"; import Link from "next/link"; import { useRouter } from "next/router"; import { useContext, useEffect, useState } from "react"; +import { toast } from "react-toastify"; type UserInfo = { displayName: string; @@ -31,25 +34,51 @@ export default function MatchFound() { const [otherUser, setOtherUser] = useState(defaultUser); const { getAppUser } = useUser(); + const [isLoading, setIsLoading] = useState(true); + + const [difficulty, setDifficulty] = useState(["medium"]); + const [selectedLanguage, setSelectedLanguage] = useState( + languages.length > 0 ? languages[0].value : "c++" + ); useEffect(() => { - const fetchOtherUser = async () => { - const otherUserId = - match?.userId1 === user?.uid ? match?.userId2 : match?.userId1; + if (user) { + getAppUser().then((user) => { + if (user) { + setDifficulty([user.matchDifficulty as Difficulty] || difficulty); + setSelectedLanguage( + user.matchProgrammingLanguage || selectedLanguage + ); + } + setIsLoading(false); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user]); - const other = await getAppUser(otherUserId, false); - if (other) { - setOtherUser({ - displayName: other.displayName || "Anonymous", - photoUrl: other.photoUrl || defaultUser.photoUrl, - }); - } + useEffect(() => { + if (!match) { + toast("Other user has left"); + router.push("/interviews"); + } else { + const fetchOtherUser = async () => { + const otherUserId = + match?.userId1 === user?.uid ? match?.userId2 : match?.userId1; - console.log(other); - }; + const other = await getAppUser(otherUserId, false); + if (other) { + setOtherUser({ + displayName: other.displayName || "Anonymous", + photoUrl: other.photoUrl || defaultUser.photoUrl, + }); + } - if (user && authIsReady) { - fetchOtherUser(); + console.log(other); + }; + + if (user && authIsReady) { + fetchOtherUser(); + } } }, [user, authIsReady, match]); @@ -58,12 +87,6 @@ export default function MatchFound() { router.push("/interviews"); }; - const onClickRetry = () => { - cancelLooking(); - joinQueue(["easy", "medium", "hard"], "python"); - router.push("/interviews/find-match"); - }; - const onClickAccept = () => { router.push(`/room/${match?.roomId}`); }; @@ -91,9 +114,9 @@ export default function MatchFound() { - + */} diff --git a/frontend/src/pages/room/[id].tsx b/frontend/src/pages/room/[id].tsx index 4e614564..5348c9ef 100644 --- a/frontend/src/pages/room/[id].tsx +++ b/frontend/src/pages/room/[id].tsx @@ -83,7 +83,7 @@ export default function Room() { updateQuestionIdInMatch(roomId, question.id); setQuestion(question); setQuestionId(question.id); - console.log(question.id); + console.log("rin"); } }) .catch((err) => { @@ -99,7 +99,7 @@ export default function Room() { function onLeaveRoomClick(): void { disconnect(); leaveMatch(); - router.push("/"); + router.push("/interviews"); } return ( @@ -131,7 +131,7 @@ export default function Room() { color="white" /> - ) : question != null ? ( + ) : question !== null ? ( { console.log(`User disconnected: ${socket.id}`); // Remove user from queue if they disconnect - prisma.waitingUser.deleteMany({ - where: { - userId: userId, - } - }).catch((err) => { - console.log(err); - }); + prisma.waitingUser + .deleteMany({ + where: { + userId: userId, + }, + }) + .catch((err) => { + console.log(err); + }); // Match should not be cancelled since the user might reconnect but we can notify the other user - prisma.match.findFirst({ + prisma.match + .findFirst({ where: { OR: [{ userId1: userId }, { userId2: userId }], }, - }).then((match) => { - if (match) { - const matchingUserId = - match?.userId1 === userId ? match?.userId2 : match?.userId1; - console.log( - `Notifying user ${matchingUserId} that user ${userId} has disconnected` - ); - io.to(match?.roomId || "").emit( - "receiveMessage", - "Server", - "Your partner has disconnected" - ); - } - }).catch((err) => { - console.log(err); - }); - - + }) + .then((match) => { + if (match) { + const matchingUserId = + match?.userId1 === userId ? match?.userId2 : match?.userId1; + console.log( + `Notifying user ${matchingUserId} that user ${userId} has disconnected` + ); + io.to(match?.roomId || "").emit( + "receiveMessage", + "Server", + "Your partner has disconnected" + ); + } + }) + .catch((err) => { + console.log(err); + }); }; } export function handleLooking( socket: Socket, - userId: string, + userId: string ): (difficulties: string[], programmingLang: string) => Promise { return async (difficulties: string[], programmingLang: string) => { if (!difficulties || !programmingLang) { @@ -93,15 +93,17 @@ export function handleLooking( } let hasError = false; - const existingMatch = await prisma.match.findFirst({ - where: { - OR: [{ userId1: userId }, { userId2: userId }], - }, - }).catch((err) => { - console.log(err); - socket.emit("error", "An error occurred in lookingForMatch."); - hasError = true; - }); + const existingMatch = await prisma.match + .findFirst({ + where: { + OR: [{ userId1: userId }, { userId2: userId }], + }, + }) + .catch((err) => { + console.log(err); + socket.emit("error", "An error occurred in lookingForMatch."); + hasError = true; + }); if (hasError) { return; @@ -121,65 +123,71 @@ export function handleLooking( return; } - let {newMatch: foundMatch, matchingUser} = await prisma.$transaction(async (tx) => { - const matchingUser = await tx.waitingUser.findFirst({ - where: { - progLang: programmingLang, - difficulty: { - hasSome: difficulties, - }, - createdAt: { - gte: new Date(Date.now() - MAX_WAITING_TIME), - } - }, - }); - if (matchingUser) { - const commonDifficulty = matchingUser.difficulty.find((v) => difficulties.includes(v)); - const newMatch = await tx.match.create({ - data: { - userId1: matchingUser.userId, - userId2: userId, - chosenDifficulty: commonDifficulty || "easy", - chosenProgrammingLanguage: programmingLang, - }, - }); - await tx.room.create({ - data: { - room_id: newMatch.roomId, - status: EnumRoomStatus.active, - text: "" - }, - }); - await tx.waitingUser.deleteMany({ + let { newMatch: foundMatch, matchingUser } = await prisma.$transaction( + async (tx) => { + const matchingUser = await tx.waitingUser.findFirst({ where: { - userId: { - in: [matchingUser.userId, userId], - }, - }, - }); - return {newMatch, matchingUser}; - } else { - await tx.waitingUser.create({ - data: { - userId: userId, progLang: programmingLang, - difficulty: difficulties, - socketId: socket.id, + difficulty: { + hasSome: difficulties, + }, + createdAt: { + gte: new Date(Date.now() - MAX_WAITING_TIME), + }, }, }); - return { - newMatch: null, - matchingUser: null, - }; + if (matchingUser) { + const commonDifficulty = matchingUser.difficulty.find((v) => + difficulties.includes(v) + ); + const newMatch = await tx.match.create({ + data: { + userId1: matchingUser.userId, + userId2: userId, + chosenDifficulty: commonDifficulty || "easy", + chosenProgrammingLanguage: programmingLang, + }, + }); + await tx.room.create({ + data: { + room_id: newMatch.roomId, + status: EnumRoomStatus.active, + text: "", + }, + }); + await tx.waitingUser.deleteMany({ + where: { + userId: { + in: [matchingUser.userId, userId], + }, + }, + }); + return { newMatch, matchingUser }; + } else { + await tx.waitingUser.create({ + data: { + userId: userId, + progLang: programmingLang, + difficulty: difficulties, + socketId: socket.id, + }, + }); + return { + newMatch: null, + matchingUser: null, + }; + } } - }); + ); if (!foundMatch) { console.log(`Queued user ${userId}.`); return; } - const qnId = await getRandomQuestionOfDifficulty(foundMatch.chosenDifficulty); + const qnId = await getRandomQuestionOfDifficulty( + foundMatch.chosenDifficulty + ); foundMatch = await prisma.match.update({ where: { roomId: foundMatch.roomId, @@ -198,13 +206,10 @@ export function handleLooking( // Inform both users of the match socket.emit("matchFound", foundMatch); io.to(matchingUser?.socketId || "").emit("matchFound", foundMatch); - }; } -export function handleCancelLooking( - userId: string -): () => Promise { +export function handleCancelLooking(userId: string): () => Promise { return async () => { console.log(`User ${userId} is no longer looking for a match`); await prisma.waitingUser.deleteMany({ @@ -215,7 +220,10 @@ export function handleCancelLooking( }; } -export function handleJoinRoom(userId: string, socket: Socket): (roomId: string) => void { +export function handleJoinRoom( + userId: string, + socket: Socket +): (roomId: string) => void { return (roomId: string) => { // TODO: Check if the user is in a match with relevant room id console.log(`User ${socket.id} is joining room ${roomId}`); @@ -229,6 +237,7 @@ export function handleLeaveMatch( ): () => Promise { return async () => { console.log(`User ${userId} has left the match`); + // socket.emit("userLeft", userId); const deletedRoom = await prisma.$transaction(async (tx) => { const match = await tx.match.findFirst({ diff --git a/yarn.lock b/yarn.lock index a5b2cddd..1ed57930 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4239,6 +4239,11 @@ clsx@2.0.0, clsx@^2.0.0: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b" integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q== +clsx@^1.1.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" + integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== + cmdk@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/cmdk/-/cmdk-0.2.0.tgz#53c52d56d8776c8bb8ced1055b5054100c388f7c" @@ -8882,6 +8887,13 @@ react-style-singleton@^2.2.1: invariant "^2.2.4" tslib "^2.0.0" +react-toastify@^9.1.3: + version "9.1.3" + resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-9.1.3.tgz#1e798d260d606f50e0fab5ee31daaae1d628c5ff" + integrity sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg== + dependencies: + clsx "^1.1.1" + react-transition-group@^4.4.5: version "4.4.5" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" From 3e7d3d2a27fc1562f0ddf946159838af8a28ca99 Mon Sep 17 00:00:00 2001 From: Ong Jun Xiong Date: Sat, 11 Nov 2023 03:32:38 +0800 Subject: [PATCH 44/50] fixed room not loading questions bug --- .../src/gateway-address/gateway-address.ts | 2 + frontend/src/hooks/useCollaboration.tsx | 42 ++++++- .../src/pages/api/collaborationHandler.ts | 33 ++++++ frontend/src/pages/room/[id].tsx | 106 ++++++++++-------- services/collaboration-service/src/app.ts | 4 +- .../collaboration-service/src/routes/room.ts | 25 ++++- 6 files changed, 156 insertions(+), 56 deletions(-) create mode 100644 frontend/src/pages/api/collaborationHandler.ts diff --git a/frontend/src/gateway-address/gateway-address.ts b/frontend/src/gateway-address/gateway-address.ts index 0ce152b0..22a736d6 100644 --- a/frontend/src/gateway-address/gateway-address.ts +++ b/frontend/src/gateway-address/gateway-address.ts @@ -20,3 +20,5 @@ export const questionApiPathAddress = httpProxyGatewayAddress + "api/question-service/"; export const matchApiPathAddress = httpProxyGatewayAddress + "api/matching-service/"; +export const collaborationApiPathAddress = + httpProxyGatewayAddress + "api/collaboration-service/"; diff --git a/frontend/src/hooks/useCollaboration.tsx b/frontend/src/hooks/useCollaboration.tsx index 6670c6a8..ca96321a 100644 --- a/frontend/src/hooks/useCollaboration.tsx +++ b/frontend/src/hooks/useCollaboration.tsx @@ -9,6 +9,10 @@ import { TextOp } from "ot-text-unicode"; import { Room, connect } from "twilio-video"; import { wsCollaborationProxyGatewayAddress } from "@/gateway-address/gateway-address"; import { AuthContext } from "@/contexts/AuthContext"; +import { toast } from "react-toastify"; +import { collaborationServiceAddress } from "./../../../services/gateway/src/proxied_routes/service_names"; +import { useRouter } from "next/router"; +import { fetchRoomData } from "@/pages/api/collaborationHandler"; type UseCollaborationProps = { roomId: string; @@ -45,7 +49,26 @@ const useCollaboration = ({ const awaitingAck = useRef(false); // ack from sending update const awaitingSync = useRef(false); // synced with server const twilioTokenRef = useRef(""); - const { user: currentUser, authIsReady } = useContext(AuthContext); + const { user: currentUser } = useContext(AuthContext); + + const router = useRouter(); + const { id } = router.query; + + useEffect(() => { + if (id && currentUser) { + try { + const response = fetchRoomData(id?.toString(), currentUser); + response.then((res) => { + if (res.message === "Room exists") { + console.log(res); + setQuestionId(res.questionId); + } + }); + } catch (err) { + toast.error((err as Error).message); + } + } + }, [id, currentUser]); useEffect(() => { if (currentUser) { @@ -58,7 +81,11 @@ const useCollaboration = ({ setSocket(socketConnection); socketConnection.emit(SocketEvents.ROOM_JOIN, roomId, userId); - if (questionId !== "") { + if ( + questionId !== "" && + questionId !== undefined && + questionId !== null + ) { socketConnection.emit(SocketEvents.QUESTION_SET, questionId); } @@ -180,7 +207,16 @@ const useCollaboration = ({ } }; - return { text, setText, cursor, setCursor, room, setQuestionId, disconnect }; + return { + text, + setText, + cursor, + setCursor, + room, + questionId, + setQuestionId, + disconnect, + }; }; export default useCollaboration; diff --git a/frontend/src/pages/api/collaborationHandler.ts b/frontend/src/pages/api/collaborationHandler.ts new file mode 100644 index 00000000..c1789a26 --- /dev/null +++ b/frontend/src/pages/api/collaborationHandler.ts @@ -0,0 +1,33 @@ +import { collaborationApiPathAddress } from "@/gateway-address/gateway-address"; + +export const fetchRoomData = async (roomId: string, user: any) => { + try { + const url = `${collaborationApiPathAddress}room/${roomId}`; + const idToken = await user.getIdToken(true); + + const response = await fetch(url, { + method: "GET", + mode: "cors", + headers: { + "Content-Type": "application/json", + "User-Id-Token": idToken, + }, + }); + + const data = await response.json(); + + if (data && data.room_id) { + return { + message: data.message, + roomId: data.room_id, + questionId: data.questionId, + info: data.info, + }; + } else { + throw new Error("Invalid data format from the server"); + } + } catch (error) { + console.error("There was an error fetching the room data", error); + throw error; + } +}; diff --git a/frontend/src/pages/room/[id].tsx b/frontend/src/pages/room/[id].tsx index 5348c9ef..3f7e1451 100644 --- a/frontend/src/pages/room/[id].tsx +++ b/frontend/src/pages/room/[id].tsx @@ -20,58 +20,58 @@ export default function Room() { const disableVideo = (router.query.disableVideo as string)?.toLowerCase() === "true"; - const { text, setText, cursor, setCursor, room, setQuestionId, disconnect } = - useCollaboration({ - roomId: roomId as string, - userId, - disableVideo, - }); + const { + text, + setText, + cursor, + setCursor, + room, + questionId, + setQuestionId, + disconnect, + } = useCollaboration({ + roomId: roomId as string, + userId, + disableVideo, + }); const [question, setQuestion] = useState(null); const [loading, setLoading] = useState(true); // to be used later for loading states - const defaultQuestion: Question = { - title: "Example Question: Two Sum", - difficulty: "Easy", - topics: ["Array", "Hash Table"], - description: - "Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.\n\nYou may assume that each input would have exactly one solution, and you may not use the same element twice.\n\nYou can return the answer in any order.", - solution: - "var twoSum = function(nums, target) {\n for (let i = 0; i < nums.length; i++) {\n for (let j = i + 1; j < nums.length; j++) {\n if (nums[i] + nums[j] === target) {\n return [i, j];\n }\n }\n }\n};", - defaultCode: { python: "var twoSum = function(nums, target) {\n\n};" }, - id: "", - author: "", - }; + // const defaultQuestion: Question = { + // title: "Example Question: Two Sum", + // difficulty: "Easy", + // topics: ["Array", "Hash Table"], + // description: + // "Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.\n\nYou may assume that each input would have exactly one solution, and you may not use the same element twice.\n\nYou can return the answer in any order.", + // solution: + // "var twoSum = function(nums, target) {\n for (let i = 0; i < nums.length; i++) {\n for (let j = i + 1; j < nums.length; j++) {\n if (nums[i] + nums[j] === target) {\n return [i, j];\n }\n }\n }\n};", + // defaultCode: { python: "var twoSum = function(nums, target) {\n\n};" }, + // id: "", + // author: "", + // }; const { fetchQuestion, fetchRandomQuestion } = useQuestions(); - const { getMatch, updateQuestionIdInMatch } = useMatch(); - const { leaveMatch } = useMatchmaking(); - const [match, setMatch] = useState(null); + const { updateQuestionIdInMatch } = useMatch(); + const { match, leaveMatch } = useMatchmaking(); useEffect(() => { - getMatch(roomId) - .then((match) => { - if (match && match.questionId != null) { - setMatch(match); - const questionId = match.questionId; - fetchQuestion(questionId).then((fetchQuestion) => { - if (fetchQuestion != null) { - setQuestion(fetchQuestion); - setQuestionId(fetchQuestion.id); - console.log(questionId); - } - }); + if (match && match.questionId !== null) { + const questionId = match.questionId; + setQuestionId(questionId); + } + + if (questionId !== "") { + fetchQuestion(questionId).then((fetchQuestion) => { + if (fetchQuestion != null) { + setQuestion(fetchQuestion); } - }) - .catch((err) => { - console.log(err); - router.push("/"); - }) - .finally(() => { - setLoading(false); }); + } + + setLoading(false); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [roomId]); + }, [match, questionId]); function handleSwapQuestionClick(): void { if (match) { @@ -83,7 +83,6 @@ export default function Room() { updateQuestionIdInMatch(roomId, question.id); setQuestion(question); setQuestionId(question.id); - console.log("rin"); } }) .catch((err) => { @@ -137,10 +136,14 @@ export default function Room() { onSwapQuestionClick={handleSwapQuestionClick} /> ) : ( - +
+ +
)} {loading ? ( @@ -157,9 +160,14 @@ export default function Room() { {question.solution} ) : ( - - {defaultQuestion.solution} - +
+ +
)}
diff --git a/services/collaboration-service/src/app.ts b/services/collaboration-service/src/app.ts index c4f2c6b8..b8d1ee58 100644 --- a/services/collaboration-service/src/app.ts +++ b/services/collaboration-service/src/app.ts @@ -7,7 +7,7 @@ import { Server as SocketIOServer } from "socket.io"; import swaggerUi from "swagger-ui-express"; import swaggerFile from "./swagger-output.json"; import bodyParser from "body-parser"; -import roomRouter from "./routes/room"; +import roomRouter, { roomApiRouter } from "./routes/room"; import demoRouter from "./routes/demo"; const app: Express = express(); @@ -33,6 +33,8 @@ app.use(express.static(path.join(__dirname, "public"))); /* Routers */ app.use("/demo", demoRouter); app.use("/api/collaboration-service/room", roomRouter(io)); +app.use("/api/collaboration-service/room", roomApiRouter); + app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerFile)); server.listen(PORT, () => { diff --git a/services/collaboration-service/src/routes/room.ts b/services/collaboration-service/src/routes/room.ts index 57f14346..7b5a30e5 100644 --- a/services/collaboration-service/src/routes/room.ts +++ b/services/collaboration-service/src/routes/room.ts @@ -40,7 +40,6 @@ enum SocketEvents { const socketMap: Record = {}; const opMap: OpHistoryMap = new OpHistoryMap(); - const AccessToken = require("twilio").jwt.AccessToken; const VideoGrant = AccessToken.VideoGrant; @@ -219,12 +218,31 @@ function getTwilioAccessToken(room_id: string, user_id: string): string { TWILIO_ACCOUNT_SID, TWILIO_API_KEY, TWILIO_API_SECRET, - { identity: user_id, ttl: 60*60*12 } + { identity: user_id, ttl: 60 * 60 * 12 } ); token.addGrant(videoGrant); return token.toJwt(); } +export const roomApiRouter = () => { + const router = express.Router(); + + router.get("/:room_id", async (req: Request, res: Response) => { + const room_id = req.params.room_id as string; + + if (!isRoomExists(room_id)) { + return res.status(404).json({ error: "Room not found" }); + } + + return res.status(200).json({ + message: "Room exists", + room_id: room_id, + questionId: await getRoom(room_id).then((room) => room.question_id), + info: await getRoom(room_id), + }); + }); +}; + export const roomRouter = (io: Server) => { const router = express.Router(); @@ -238,6 +256,7 @@ export const roomRouter = (io: Server) => { return res.status(200).json({ message: "Room exists", room_id: room_id, + questionId: await getRoom(room_id).then((room) => room.question_id), info: await getRoom(room_id), }); }); @@ -275,7 +294,7 @@ export const roomRouter = (io: Server) => { createOrUpdateRoomWithUser(room_id, user_id); mapSocketToRoomAndUser(socket.id, room_id, user_id); roomUpdateWithTextFromDb(io, socket, room_id); - socket.emit("twilio-token", getTwilioAccessToken(room_id, user_id)) + socket.emit("twilio-token", getTwilioAccessToken(room_id, user_id)); initSocketListeners(io, socket, room_id); }); From 475ef8c7de3003505b25d447461d8c3005f5cafe Mon Sep 17 00:00:00 2001 From: Charisma Kausar Date: Sat, 11 Nov 2023 04:36:28 +0800 Subject: [PATCH 45/50] feat: sanitize question description and wrap pre tag --- frontend/src/components/room/description.tsx | 5 ++++- frontend/src/styles/globals.scss | 13 +++++++++++++ services/question-service/src/routes/index.ts | 1 - 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/room/description.tsx b/frontend/src/components/room/description.tsx index f0c708fa..a8c70903 100644 --- a/frontend/src/components/room/description.tsx +++ b/frontend/src/components/room/description.tsx @@ -3,6 +3,7 @@ import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Card } from "../ui/card"; import { TypographyH2, TypographySmall } from "../ui/typography"; +import sanitizeHtml from "sanitize-html"; type DescriptionProps = { question: Question; @@ -17,6 +18,8 @@ export default function Description({ onSwapQuestionClick, hasRoom = true, }: DescriptionProps) { + const cleanDescription = sanitizeHtml(question.description) + return (
diff --git a/frontend/src/styles/globals.scss b/frontend/src/styles/globals.scss index 60e88e85..6d38c9f0 100644 --- a/frontend/src/styles/globals.scss +++ b/frontend/src/styles/globals.scss @@ -373,3 +373,16 @@ html { box-sizing: inherit; } } + +pre { + white-space: pre-wrap; + /* Since CSS 2.1 */ + white-space: -moz-pre-wrap; + /* Mozilla, since 1999 */ + white-space: -pre-wrap; + /* Opera 4-6 */ + white-space: -o-pre-wrap; + /* Opera 7 */ + word-wrap: break-word; + /* Internet Explorer 5.5+ */ +} diff --git a/services/question-service/src/routes/index.ts b/services/question-service/src/routes/index.ts index 983c146a..0b96647a 100644 --- a/services/question-service/src/routes/index.ts +++ b/services/question-service/src/routes/index.ts @@ -4,7 +4,6 @@ import sanitizeHtml from "sanitize-html"; import { MongoClient, ObjectId, ServerApiVersion } from "mongodb"; import { NewQuestion, isDifficulty } from "../models/new_question.model"; import { Question } from "../models/question.model"; -import { kebabToProperCase } from "./utils"; export const router = express.Router(); From 47e2823be2f913228aedfc64d7a9b05dc1387e57 Mon Sep 17 00:00:00 2001 From: Charisma Kausar Date: Sat, 11 Nov 2023 04:45:15 +0800 Subject: [PATCH 46/50] feat: remove console in code editor --- frontend/package.json | 1 + frontend/src/components/room/code-editor.tsx | 11 ++--------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 5f6e3ee9..a07133a2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,6 +48,7 @@ "react-dom": "18.2.0", "react-hook-form": "^7.47.0", "react-icons": "^4.11.0", + "sanitize-html": "^2.11.0", "sass": "^1.69.0", "socket.io-client": "^4.7.2", "tailwind-merge": "^1.14.0", diff --git a/frontend/src/components/room/code-editor.tsx b/frontend/src/components/room/code-editor.tsx index 6c6e6739..4046a112 100644 --- a/frontend/src/components/room/code-editor.tsx +++ b/frontend/src/components/room/code-editor.tsx @@ -24,7 +24,6 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { Card } from "../ui/card"; -import { TypographyBody, TypographyBodyHeavy } from "../ui/typography"; import { editor } from "monaco-editor"; type CodeEditorProps = { @@ -60,7 +59,7 @@ export const languages = [ export default function CodeEditor({ theme = "vs-dark", language = "python", - height = "60vh", + height = "70vh", defaultValue = "#Write your solution here", className, text, @@ -193,20 +192,14 @@ export default function CodeEditor({ onMount={editorMount} /> -
- Console -
- {/* */} {hasRoom ? ( ) : (
+
+ { testCases.length > 0 && ( +
+ + + + + + + + + {testCases.map((testCase, index) => ( + + + + + ))} + +
Test Case InputTest Case Output
{testCase.input}{testCase.output}
+
)}
From 6fd9547b2a745c0f5bb1fa61143cccebb8b9da5b Mon Sep 17 00:00:00 2001 From: Charisma Kausar Date: Sat, 11 Nov 2023 05:34:20 +0800 Subject: [PATCH 48/50] feat: add solution ui for questions/room --- frontend/package.json | 2 + frontend/src/components/room/solution.tsx | 49 +++++++ frontend/src/pages/questions/[id]/index.tsx | 9 +- frontend/src/pages/room/[id].tsx | 18 +-- yarn.lock | 152 +++++++++++++++++++- 5 files changed, 214 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/room/solution.tsx diff --git a/frontend/package.json b/frontend/package.json index a07133a2..6762e617 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,6 +48,7 @@ "react-dom": "18.2.0", "react-hook-form": "^7.47.0", "react-icons": "^4.11.0", + "react-syntax-highlighter": "^15.5.0", "sanitize-html": "^2.11.0", "sass": "^1.69.0", "socket.io-client": "^4.7.2", @@ -62,6 +63,7 @@ "@types/lodash": "^4.14.199", "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", + "@types/react-syntax-highlighter": "^15.5.10", "@types/socket.io-client": "^3.0.0", "eslint-config-next": "^13.5.6" } diff --git a/frontend/src/components/room/solution.tsx b/frontend/src/components/room/solution.tsx new file mode 100644 index 00000000..99d3a145 --- /dev/null +++ b/frontend/src/components/room/solution.tsx @@ -0,0 +1,49 @@ +import { Question } from "../../types/QuestionTypes"; +import { Card } from "../ui/card"; +import { TypographyH2, TypographySmall } from "../ui/typography"; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism'; + +type SolutionProps = { + question: Question; + className?: string; + hasRoom?: boolean; +}; + +export default function Solution({ + question, + className, + hasRoom = true, +}: SolutionProps) { + + return ( + +
+
+ Solution +
+
+
+ + + { question.solution ? question.solution : `# Sample solution code to demo syntax highlighting. +import sys +class Solution(object): + def isValidBST(self, root): + return self.isVaild_helper(root, -sys.maxint - 1, sys.maxint) + + def isVaild_helper(self, root, minVal, maxVal): + if root is None: + return True + if root.val >= maxVal or root.val <= minVal: + return False + return self.isVaild_helper(root.left, minVal, root.val) and self.isVaild_helper(root.right, root.val, maxVal) +`} + + +
+
+ ); +} diff --git a/frontend/src/pages/questions/[id]/index.tsx b/frontend/src/pages/questions/[id]/index.tsx index 7a71421c..14ac3ac0 100644 --- a/frontend/src/pages/questions/[id]/index.tsx +++ b/frontend/src/pages/questions/[id]/index.tsx @@ -9,6 +9,7 @@ import { AuthContext } from "@/contexts/AuthContext"; import { fetchQuestion } from "../../api/questionHandler"; import { MrMiyagi } from "@uiball/loaders"; import { useHistory } from "@/hooks/useHistory"; +import Solution from "@/components/room/solution"; export default function Questions() { const router = useRouter(); @@ -83,7 +84,13 @@ export default function Questions() { hasRoom={false} /> - {question.solution} + + +
(null); const [loading, setLoading] = useState(true); // to be used later for loading states - // const defaultQuestion: Question = { - // title: "Example Question: Two Sum", - // difficulty: "Easy", - // topics: ["Array", "Hash Table"], - // description: - // "Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.\n\nYou may assume that each input would have exactly one solution, and you may not use the same element twice.\n\nYou can return the answer in any order.", - // solution: - // "var twoSum = function(nums, target) {\n for (let i = 0; i < nums.length; i++) {\n for (let j = i + 1; j < nums.length; j++) {\n if (nums[i] + nums[j] === target) {\n return [i, j];\n }\n }\n }\n};", - // defaultCode: { python: "var twoSum = function(nums, target) {\n\n};" }, - // id: "", - // author: "", - // }; - const { fetchQuestion, fetchRandomQuestion } = useQuestions(); const { updateQuestionIdInMatch } = useMatch(); const { match, leaveMatch } = useMatchmaking(); @@ -157,7 +145,9 @@ export default function Room() {
) : question != null && "solution" in question ? ( - {question.solution} + ) : (
diff --git a/yarn.lock b/yarn.lock index 1ed57930..65087e3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -929,7 +929,7 @@ resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310" integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== -"@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.23.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.23.1", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": version "7.23.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885" integrity sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg== @@ -2924,6 +2924,13 @@ "@types/minimatch" "^5.1.2" "@types/node" "*" +"@types/hast@^2.0.0": + version "2.3.8" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.8.tgz#4ac5caf38b262b7bd5ca3202dda71f0271635660" + integrity sha512-aMIqAlFd2wTIDZuvLbhUT+TGvMxrNC8ECUIVtH6xxy0sQLs3iu6NO8Kp/VT5je7i5ufnebXzdV1dNDMnvaH6IQ== + dependencies: + "@types/unist" "^2" + "@types/http-errors@*": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.3.tgz#c54e61f79b3947d040f150abd58f71efb422ff62" @@ -3147,6 +3154,11 @@ resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.4.tgz#a1d5f480245db86e2f4777000065d4fe7467a012" integrity sha512-HlJjF3wxV4R2VQkFpKe0YqJLilYNgtRtsqqZtby7RkVsSs+i+vbyzjtUwpFEdUCKcrGzCiEJE7F/0mKjh0sunA== +"@types/unist@^2": + version "2.0.10" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.10.tgz#04ffa7f406ab628f7f7e97ca23e290cd8ab15efc" + integrity sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA== + "@types/uuid@^9.0.4": version "9.0.6" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.6.tgz#c91ae743d8344a54b2b0c691195f5ff5265f6dfb" @@ -4116,6 +4128,21 @@ chalk@^5.2.0: resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== +character-entities-legacy@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz#94bc1845dce70a5bb9d2ecc748725661293d8fc1" + integrity sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA== + +character-entities@^1.0.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.4.tgz#e12c3939b7eaf4e5b15e7ad4c5e28e1d48c5b16b" + integrity sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw== + +character-reference-invalid@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560" + integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg== + chardet@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" @@ -4322,6 +4349,11 @@ combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" +comma-separated-tokens@^1.0.0: + version "1.0.8" + resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea" + integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw== + command-score@0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/command-score/-/command-score-0.1.2.tgz#b986ad7e8c0beba17552a56636c44ae38363d381" @@ -5596,6 +5628,13 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" +fault@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/fault/-/fault-1.0.4.tgz#eafcfc0a6d214fc94601e170df29954a4f842f13" + integrity sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA== + dependencies: + format "^0.2.0" + faye-websocket@0.11.4: version "0.11.4" resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" @@ -5850,6 +5889,11 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +format@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" + integrity sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww== + formidable@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/formidable/-/formidable-2.1.2.tgz#fa973a2bec150e4ce7cac15589d7a25fc30ebd89" @@ -6378,6 +6422,22 @@ hasown@^2.0.0: dependencies: function-bind "^1.1.2" +hast-util-parse-selector@^2.0.0: + version "2.2.5" + resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz#d57c23f4da16ae3c63b3b6ca4616683313499c3a" + integrity sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ== + +hastscript@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/hastscript/-/hastscript-6.0.0.tgz#e8768d7eac56c3fdeac8a92830d58e811e5bf640" + integrity sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w== + dependencies: + "@types/hast" "^2.0.0" + comma-separated-tokens "^1.0.0" + hast-util-parse-selector "^2.0.0" + property-information "^5.0.0" + space-separated-tokens "^1.0.0" + heap-js@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/heap-js/-/heap-js-2.3.0.tgz#8eed2cede31ec312aa696eef1d4df0565841f183" @@ -6388,6 +6448,11 @@ hexoid@^1.0.0: resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18" integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g== +highlight.js@^10.4.1, highlight.js@~10.7.0: + version "10.7.3" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531" + integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A== + hoist-non-react-statics@^3.3.1: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -6647,6 +6712,19 @@ ipaddr.js@1.9.1: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== +is-alphabetical@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz#9e7d6b94916be22153745d184c298cbf986a686d" + integrity sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg== + +is-alphanumerical@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz#7eb9a2431f855f6b1ef1a78e326df515696c4dbf" + integrity sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A== + dependencies: + is-alphabetical "^1.0.0" + is-decimal "^1.0.0" + is-arguments@^1.0.4: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" @@ -6729,6 +6807,11 @@ is-date-object@^1.0.1, is-date-object@^1.0.5: dependencies: has-tostringtag "^1.0.0" +is-decimal@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.4.tgz#65a3a5958a1c5b63a706e1b333d7cd9f630d3fa5" + integrity sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw== + is-dir@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-dir/-/is-dir-1.0.0.tgz#41d37f495fccacc05a4778d66e83024c292ba3ff" @@ -6765,6 +6848,11 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" +is-hexadecimal@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7" + integrity sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw== + is-installed-globally@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" @@ -7492,6 +7580,14 @@ loupe@^2.3.6: dependencies: get-func-name "^2.0.1" +lowlight@^1.17.0: + version "1.20.0" + resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-1.20.0.tgz#ddb197d33462ad0d93bf19d17b6c301aa3941888" + integrity sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw== + dependencies: + fault "^1.0.0" + highlight.js "~10.7.0" + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -8355,6 +8451,18 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" +parse-entities@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-2.0.0.tgz#53c6eb5b9314a1f4ec99fa0fdf7ce01ecda0cbe8" + integrity sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ== + dependencies: + character-entities "^1.0.0" + character-entities-legacy "^1.0.0" + character-reference-invalid "^1.0.0" + is-alphanumerical "^1.0.0" + is-decimal "^1.0.0" + is-hexadecimal "^1.0.0" + parse-json@^5.0.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" @@ -8580,6 +8688,16 @@ prisma@^5.4.2: dependencies: "@prisma/engines" "5.4.2" +prismjs@^1.27.0: + version "1.29.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.29.0.tgz#f113555a8fa9b57c35e637bba27509dcf802dd12" + integrity sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q== + +prismjs@~1.27.0: + version "1.27.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.27.0.tgz#bb6ee3138a0b438a3653dd4d6ce0cc6510a45057" + integrity sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA== + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -8612,6 +8730,13 @@ prop-types@^15.6.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +property-information@^5.0.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.6.0.tgz#61675545fb23002f245c6540ec46077d4da3ed69" + integrity sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA== + dependencies: + xtend "^4.0.0" + proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -8887,6 +9012,17 @@ react-style-singleton@^2.2.1: invariant "^2.2.4" tslib "^2.0.0" +react-syntax-highlighter@^15.5.0: + version "15.5.0" + resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-15.5.0.tgz#4b3eccc2325fa2ec8eff1e2d6c18fa4a9e07ab20" + integrity sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg== + dependencies: + "@babel/runtime" "^7.3.1" + highlight.js "^10.4.1" + lowlight "^1.17.0" + prismjs "^1.27.0" + refractor "^3.6.0" + react-toastify@^9.1.3: version "9.1.3" resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-9.1.3.tgz#1e798d260d606f50e0fab5ee31daaae1d628c5ff" @@ -8980,6 +9116,15 @@ reflect.getprototypeof@^1.0.4: globalthis "^1.0.3" which-builtin-type "^1.1.3" +refractor@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/refractor/-/refractor-3.6.0.tgz#ac318f5a0715ead790fcfb0c71f4dd83d977935a" + integrity sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA== + dependencies: + hastscript "^6.0.0" + parse-entities "^2.0.0" + prismjs "~1.27.0" + regenerate-unicode-properties@^10.1.0: version "10.1.1" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz#6b0e05489d9076b04c436f318d9b067bba459480" @@ -9534,6 +9679,11 @@ source-map@^0.6.0, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +space-separated-tokens@^1.0.0: + version "1.1.5" + resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz#85f32c3d10d9682007e917414ddc5c26d1aa6899" + integrity sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA== + sparse-bitfield@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz#ff4ae6e68656056ba4b3e792ab3334d38273ca11" From c7db5f2591127d9c686aa657fdb97cb7ffa3cd2b Mon Sep 17 00:00:00 2001 From: Charisma Kausar Date: Sat, 11 Nov 2023 06:09:01 +0800 Subject: [PATCH 49/50] feat: add md support for question description --- frontend/package.json | 2 + frontend/src/components/room/description.tsx | 9 +- yarn.lock | 581 ++++++++++++++++++- 3 files changed, 584 insertions(+), 8 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 6762e617..01f3fdda 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,7 +48,9 @@ "react-dom": "18.2.0", "react-hook-form": "^7.47.0", "react-icons": "^4.11.0", + "react-markdown": "^9.0.0", "react-syntax-highlighter": "^15.5.0", + "rehype-raw": "^7.0.0", "sanitize-html": "^2.11.0", "sass": "^1.69.0", "socket.io-client": "^4.7.2", diff --git a/frontend/src/components/room/description.tsx b/frontend/src/components/room/description.tsx index d56b265c..612ce687 100644 --- a/frontend/src/components/room/description.tsx +++ b/frontend/src/components/room/description.tsx @@ -4,6 +4,8 @@ import { Button } from "../ui/button"; import { Card } from "../ui/card"; import { TypographyH2, TypographySmall } from "../ui/typography"; import sanitizeHtml from "sanitize-html"; +import Markdown from 'react-markdown' +import rehypeRaw from 'rehype-raw' type DescriptionProps = { question: Question; @@ -52,10 +54,9 @@ export default function Description({
-
+
+ {cleanDescription} +

{ testCases.length > 0 && (
diff --git a/yarn.lock b/yarn.lock index 65087e3e..7d275ec1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2877,6 +2877,13 @@ dependencies: "@types/node" "*" +"@types/debug@^4.0.0": + version "4.1.12" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" + integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== + dependencies: + "@types/ms" "*" + "@types/diff-match-patch@^1.0.34": version "1.0.35" resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.35.tgz#40e8b2a02d47f648154936c39098a594099d2a80" @@ -2931,6 +2938,13 @@ dependencies: "@types/unist" "^2" +"@types/hast@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.3.tgz#7f75e6b43bc3f90316046a287d9ad3888309f7e1" + integrity sha512-2fYGlaDy/qyLlhidX42wAH0KBi2TCjKMH8CHmBXgRlJ3Y+OXTiqsPQ6IWarZKwF1JoUcAJdPogv1d4b0COTpmQ== + dependencies: + "@types/unist" "*" + "@types/http-errors@*": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.3.tgz#c54e61f79b3947d040f150abd58f71efb422ff62" @@ -2983,6 +2997,13 @@ "@types/linkify-it" "*" "@types/mdurl" "*" +"@types/mdast@^4.0.0": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.3.tgz#1e011ff013566e919a4232d1701ad30d70cab333" + integrity sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg== + dependencies: + "@types/unist" "*" + "@types/mdurl@*": version "1.0.4" resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-1.0.4.tgz#574bfbec51eb41ab5f444116c8555bc4347feba5" @@ -3010,6 +3031,11 @@ dependencies: "@types/node" "*" +"@types/ms@*": + version "0.7.34" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" + integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== + "@types/node@*", "@types/node@>=10.0.0", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@^20.8.7": version "20.8.8" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.8.tgz#adee050b422061ad5255fc38ff71b2bb96ea2a0e" @@ -3044,6 +3070,13 @@ dependencies: "@types/react" "*" +"@types/react-syntax-highlighter@^15.5.10": + version "15.5.10" + resolved "https://registry.yarnpkg.com/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.10.tgz#697dd4c640baefbfce655d3cd2b54629922ec05f" + integrity sha512-Vf8nNkGHnjwK37b2wDs92zJSAWS2Mb57NcYHgajCNssHeTNEixvjINnXJkKdY0V0/eLrYkPP1xDKvNmYIr4HIg== + dependencies: + "@types/react" "*" + "@types/react-transition-group@^4.4.7": version "4.4.8" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.8.tgz#46f87d80512959cac793ecc610a93d80ef241ccf" @@ -3154,6 +3187,11 @@ resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.4.tgz#a1d5f480245db86e2f4777000065d4fe7467a012" integrity sha512-HlJjF3wxV4R2VQkFpKe0YqJLilYNgtRtsqqZtby7RkVsSs+i+vbyzjtUwpFEdUCKcrGzCiEJE7F/0mKjh0sunA== +"@types/unist@*", "@types/unist@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.2.tgz#6dd61e43ef60b34086287f83683a5c1b2dc53d20" + integrity sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ== + "@types/unist@^2": version "2.0.10" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.10.tgz#04ffa7f406ab628f7f7e97ca23e290cd8ab15efc" @@ -3267,7 +3305,7 @@ resolved "https://registry.yarnpkg.com/@uiball/loaders/-/loaders-1.3.0.tgz#375a87dbcaa681596a4e0e455c4b4c4bbce7b49c" integrity sha512-w372e7PMt/s6LZ321HoghgDDU8fomamAzJfrVAdBUhsWERJEpxJMqG37NFztUq/T4J7nzzjkvZI4UX7Z2F/O6A== -"@ungap/structured-clone@^1.2.0": +"@ungap/structured-clone@^1.0.0", "@ungap/structured-clone@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== @@ -3826,6 +3864,11 @@ babel-plugin-polyfill-regenerator@^0.5.3: dependencies: "@babel/helper-define-polyfill-provider" "^0.4.3" +bail@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/bail/-/bail-2.0.2.tgz#d26f5cd8fe5d6f832a31517b9f7c356040ba6d5d" + integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw== + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -4138,6 +4181,11 @@ character-entities@^1.0.0: resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.4.tgz#e12c3939b7eaf4e5b15e7ad4c5e28e1d48c5b16b" integrity sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw== +character-entities@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22" + integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ== + character-reference-invalid@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560" @@ -4354,6 +4402,11 @@ comma-separated-tokens@^1.0.0: resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea" integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw== +comma-separated-tokens@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" + integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== + command-score@0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/command-score/-/command-score-0.1.2.tgz#b986ad7e8c0beba17552a56636c44ae38363d381" @@ -4636,7 +4689,7 @@ debug@2.6.9, debug@~2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2: +debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -4657,6 +4710,13 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +decode-named-character-reference@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz#daabac9690874c394c81e4162a0304b35d824f0e" + integrity sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg== + dependencies: + character-entities "^2.0.0" + deep-eql@^4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.3.tgz#7c7775513092f7df98d8df9996dd085eb668cc6d" @@ -4750,7 +4810,7 @@ depd@~1.1.2: resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== -dequal@^2.0.3: +dequal@^2.0.0, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== @@ -4765,6 +4825,13 @@ detect-node-es@^1.1.0: resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== +devlop@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" + integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== + dependencies: + dequal "^2.0.0" + dezalgo@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" @@ -5547,7 +5614,7 @@ express@^4.16.4, express@^4.18.2: utils-merge "1.0.1" vary "~1.1.2" -extend@^3.0.2, extend@~3.0.2: +extend@^3.0.0, extend@^3.0.2, extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== @@ -6422,11 +6489,86 @@ hasown@^2.0.0: dependencies: function-bind "^1.1.2" +hast-util-from-parse5@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/hast-util-from-parse5/-/hast-util-from-parse5-8.0.1.tgz#654a5676a41211e14ee80d1b1758c399a0327651" + integrity sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + devlop "^1.0.0" + hastscript "^8.0.0" + property-information "^6.0.0" + vfile "^6.0.0" + vfile-location "^5.0.0" + web-namespaces "^2.0.0" + hast-util-parse-selector@^2.0.0: version "2.2.5" resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz#d57c23f4da16ae3c63b3b6ca4616683313499c3a" integrity sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ== +hast-util-parse-selector@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz#352879fa86e25616036037dd8931fb5f34cb4a27" + integrity sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-raw@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/hast-util-raw/-/hast-util-raw-9.0.1.tgz#2ba8510e4ed2a1e541cde2a4ebb5c38ab4c82c2d" + integrity sha512-5m1gmba658Q+lO5uqL5YNGQWeh1MYWZbZmWrM5lncdcuiXuo5E2HT/CIOp0rLF8ksfSwiCVJ3twlgVRyTGThGA== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + "@ungap/structured-clone" "^1.0.0" + hast-util-from-parse5 "^8.0.0" + hast-util-to-parse5 "^8.0.0" + html-void-elements "^3.0.0" + mdast-util-to-hast "^13.0.0" + parse5 "^7.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + web-namespaces "^2.0.0" + zwitch "^2.0.0" + +hast-util-to-jsx-runtime@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.2.0.tgz#ffd59bfcf0eb8321c6ed511bfc4b399ac3404bc2" + integrity sha512-wSlp23N45CMjDg/BPW8zvhEi3R+8eRE1qFbjEyAUzMCzu2l1Wzwakq+Tlia9nkCtEl5mDxa7nKHsvYJ6Gfn21A== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + comma-separated-tokens "^2.0.0" + hast-util-whitespace "^3.0.0" + property-information "^6.0.0" + space-separated-tokens "^2.0.0" + style-to-object "^0.4.0" + unist-util-position "^5.0.0" + vfile-message "^4.0.0" + +hast-util-to-parse5@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz#477cd42d278d4f036bc2ea58586130f6f39ee6ed" + integrity sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw== + dependencies: + "@types/hast" "^3.0.0" + comma-separated-tokens "^2.0.0" + devlop "^1.0.0" + property-information "^6.0.0" + space-separated-tokens "^2.0.0" + web-namespaces "^2.0.0" + zwitch "^2.0.0" + +hast-util-whitespace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621" + integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw== + dependencies: + "@types/hast" "^3.0.0" + hastscript@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/hastscript/-/hastscript-6.0.0.tgz#e8768d7eac56c3fdeac8a92830d58e811e5bf640" @@ -6438,6 +6580,17 @@ hastscript@^6.0.0: property-information "^5.0.0" space-separated-tokens "^1.0.0" +hastscript@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/hastscript/-/hastscript-8.0.0.tgz#4ef795ec8dee867101b9f23cc830d4baf4fd781a" + integrity sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw== + dependencies: + "@types/hast" "^3.0.0" + comma-separated-tokens "^2.0.0" + hast-util-parse-selector "^4.0.0" + property-information "^6.0.0" + space-separated-tokens "^2.0.0" + heap-js@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/heap-js/-/heap-js-2.3.0.tgz#8eed2cede31ec312aa696eef1d4df0565841f183" @@ -6460,6 +6613,16 @@ hoist-non-react-statics@^3.3.1: dependencies: react-is "^16.7.0" +html-url-attributes@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-url-attributes/-/html-url-attributes-3.0.0.tgz#fc4abf0c3fb437e2329c678b80abb3c62cff6f08" + integrity sha512-/sXbVCWayk6GDVg3ctOX6nxaVj7So40FcFAnWlWGNAB1LpYKcV5Cd10APjPjW80O7zYW2MsjBV4zZ7IZO5fVow== + +html-void-elements@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7" + integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg== + htmlparser2@^8.0.0: version "8.0.2" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" @@ -6645,6 +6808,11 @@ ini@^1.3.4, ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== +inline-style-parser@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1" + integrity sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q== + inquirer@^8.2.0: version "8.2.6" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.6.tgz#733b74888195d8d400a67ac332011b5fae5ea562" @@ -6913,6 +7081,11 @@ is-plain-obj@^3.0.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7" integrity sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA== +is-plain-obj@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" + integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== + is-plain-object@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" @@ -7714,6 +7887,45 @@ marked@^4.0.10, marked@^4.0.14: resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3" integrity sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A== +mdast-util-from-markdown@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.0.tgz#52f14815ec291ed061f2922fd14d6689c810cb88" + integrity sha512-n7MTOr/z+8NAX/wmhhDji8O3bRvPTV/U0oTCaZJkjhPSKTPhS3xufVhKGF8s1pJ7Ox4QgoIU7KHseh09S+9rTA== + dependencies: + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + mdast-util-to-string "^4.0.0" + micromark "^4.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-decode-string "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + unist-util-stringify-position "^4.0.0" + +mdast-util-to-hast@^13.0.0: + version "13.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.0.2.tgz#74c0a9f014bb2340cae6118f6fccd75467792be7" + integrity sha512-U5I+500EOOw9e3ZrclN3Is3fRpw8c19SMyNZlZ2IS+7vLsNzb2Om11VpIVOR+/0137GhZsFEF6YiKD5+0Hr2Og== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + devlop "^1.0.0" + micromark-util-sanitize-uri "^2.0.0" + trim-lines "^3.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + +mdast-util-to-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz#7a5121475556a04e7eddeb67b264aae79d312814" + integrity sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg== + dependencies: + "@types/mdast" "^4.0.0" + mdurl@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" @@ -7744,6 +7956,200 @@ methods@^1.1.2, methods@~1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== +micromark-core-commonmark@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.0.tgz#50740201f0ee78c12a675bf3e68ffebc0bf931a3" + integrity sha512-jThOz/pVmAYUtkroV3D5c1osFXAMv9e0ypGDOIZuCeAe91/sD6BoE2Sjzt30yuXtwOYUmySOhMas/PVyh02itA== + dependencies: + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-factory-destination "^2.0.0" + micromark-factory-label "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-factory-title "^2.0.0" + micromark-factory-whitespace "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-html-tag-name "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-destination@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.0.tgz#857c94debd2c873cba34e0445ab26b74f6a6ec07" + integrity sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-label@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-2.0.0.tgz#17c5c2e66ce39ad6f4fc4cbf40d972f9096f726a" + integrity sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw== + dependencies: + devlop "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-space@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz#5e7afd5929c23b96566d0e1ae018ae4fcf81d030" + integrity sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-title@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-2.0.0.tgz#726140fc77892af524705d689e1cf06c8a83ea95" + integrity sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-whitespace@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.0.tgz#9e92eb0f5468083381f923d9653632b3cfb5f763" + integrity sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-character@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.0.1.tgz#52b824c2e2633b6fb33399d2ec78ee2a90d6b298" + integrity sha512-3wgnrmEAJ4T+mGXAUfMvMAbxU9RDG43XmGce4j6CwPtVxB3vfwXSZ6KhFwDzZ3mZHhmPimMAXg71veiBGzeAZw== + dependencies: + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-chunked@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-2.0.0.tgz#e51f4db85fb203a79dbfef23fd41b2f03dc2ef89" + integrity sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-classify-character@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.0.tgz#8c7537c20d0750b12df31f86e976d1d951165f34" + integrity sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-combine-extensions@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.0.tgz#75d6ab65c58b7403616db8d6b31315013bfb7ee5" + integrity sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ== + dependencies: + micromark-util-chunked "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-decode-numeric-character-reference@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.1.tgz#2698bbb38f2a9ba6310e359f99fcb2b35a0d2bd5" + integrity sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-decode-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.0.tgz#7dfa3a63c45aecaa17824e656bcdb01f9737154a" + integrity sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA== + dependencies: + decode-named-character-reference "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-encode@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz#0921ac7953dc3f1fd281e3d1932decfdb9382ab1" + integrity sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA== + +micromark-util-html-tag-name@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.0.tgz#ae34b01cbe063363847670284c6255bb12138ec4" + integrity sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw== + +micromark-util-normalize-identifier@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.0.tgz#91f9a4e65fe66cc80c53b35b0254ad67aa431d8b" + integrity sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-resolve-all@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.0.tgz#189656e7e1a53d0c86a38a652b284a252389f364" + integrity sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA== + dependencies: + micromark-util-types "^2.0.0" + +micromark-util-sanitize-uri@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz#ec8fbf0258e9e6d8f13d9e4770f9be64342673de" + integrity sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-subtokenize@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.0.tgz#9f412442d77e0c5789ffdf42377fa8a2bcbdf581" + integrity sha512-vc93L1t+gpR3p8jxeVdaYlbV2jTYteDje19rNSS/H5dlhxUYll5Fy6vJ2cDwP8RnsXi818yGty1ayP55y3W6fg== + dependencies: + devlop "^1.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-symbol@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz#12225c8f95edf8b17254e47080ce0862d5db8044" + integrity sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw== + +micromark-util-types@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.0.tgz#63b4b7ffeb35d3ecf50d1ca20e68fc7caa36d95e" + integrity sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w== + +micromark@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/micromark/-/micromark-4.0.0.tgz#84746a249ebd904d9658cfabc1e8e5f32cbc6249" + integrity sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ== + dependencies: + "@types/debug" "^4.0.0" + debug "^4.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-core-commonmark "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-combine-extensions "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" @@ -8478,6 +8884,13 @@ parse-srcset@^1.0.2: resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q== +parse5@^7.0.0: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" + integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== + dependencies: + entities "^4.4.0" + parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -8737,6 +9150,11 @@ property-information@^5.0.0: dependencies: xtend "^4.0.0" +property-information@^6.0.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.4.0.tgz#6bc4c618b0c2d68b3bb8b552cbb97f8e300a0f82" + integrity sha512-9t5qARVofg2xQqKtytzt+lZ4d1Qvj8t5B8fEwXK6qOfgRLgH/b13QlgEyDh033NOS31nXeFbYv7CLUDG1CeifQ== + proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -8973,6 +9391,23 @@ react-is@^18.0.0, react-is@^18.2.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-markdown@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-9.0.0.tgz#7a41bde9e1b0b1d6911f6f9f8c3cdb4a3e9f9328" + integrity sha512-v6yNf3AB8GfJ8lCpUvzxAXKxgsHpdmWPlcVRQ6Nocsezp255E/IDrF31kLQsPJeB/cKto/geUwjU36wH784FCA== + dependencies: + "@types/hast" "^3.0.0" + devlop "^1.0.0" + hast-util-to-jsx-runtime "^2.0.0" + html-url-attributes "^3.0.0" + mdast-util-to-hast "^13.0.0" + micromark-util-sanitize-uri "^2.0.0" + remark-parse "^11.0.0" + remark-rehype "^11.0.0" + unified "^11.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + react-remove-scroll-bar@^2.3.3: version "2.3.4" resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz#53e272d7a5cb8242990c7f144c44d8bd8ab5afd9" @@ -9191,6 +9626,36 @@ regjsparser@^0.9.1: dependencies: jsesc "~0.5.0" +rehype-raw@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/rehype-raw/-/rehype-raw-7.0.0.tgz#59d7348fd5dbef3807bbaa1d443efd2dd85ecee4" + integrity sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww== + dependencies: + "@types/hast" "^3.0.0" + hast-util-raw "^9.0.0" + vfile "^6.0.0" + +remark-parse@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-11.0.0.tgz#aa60743fcb37ebf6b069204eb4da304e40db45a1" + integrity sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-from-markdown "^2.0.0" + micromark-util-types "^2.0.0" + unified "^11.0.0" + +remark-rehype@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-11.0.0.tgz#7f21c08738bde024be5f16e4a8b13e5d7a04cf6b" + integrity sha512-vx8x2MDMcxuE4lBmQ46zYUDfcFMmvg80WYX+UNLeG6ixjdCCLcw1lrgAukwBTuOFsS78eoAedHGn9sNM0w7TPw== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + mdast-util-to-hast "^13.0.0" + unified "^11.0.0" + vfile "^6.0.0" + request@^2.87.0: version "2.88.2" resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" @@ -9684,6 +10149,11 @@ space-separated-tokens@^1.0.0: resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz#85f32c3d10d9682007e917414ddc5c26d1aa6899" integrity sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA== +space-separated-tokens@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f" + integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== + sparse-bitfield@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz#ff4ae6e68656056ba4b3e792ab3334d38273ca11" @@ -9897,6 +10367,13 @@ stubs@^3.0.0: resolved "https://registry.yarnpkg.com/stubs/-/stubs-3.0.0.tgz#e8d2ba1fa9c90570303c030b6900f7d5f89abe5b" integrity sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw== +style-to-object@^0.4.0: + version "0.4.4" + resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-0.4.4.tgz#266e3dfd56391a7eefb7770423612d043c3f33ec" + integrity sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg== + dependencies: + inline-style-parser "0.1.1" + styled-jsx@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.1.tgz#839a1c3aaacc4e735fed0781b8619ea5d0009d1f" @@ -10218,11 +10695,21 @@ tree-kill@^1.2.2: resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== +trim-lines@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" + integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== + triple-beam@^1.3.0: version "1.4.1" resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.4.1.tgz#6fde70271dc6e5d73ca0c3b24e2d92afb7441984" integrity sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg== +trough@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/trough/-/trough-2.1.0.tgz#0f7b511a4fde65a46f18477ab38849b22c554876" + integrity sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g== + ts-api-utils@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.0.3.tgz#f12c1c781d04427313dbac808f453f050e54a331" @@ -10495,6 +10982,19 @@ unicount@1.1: resolved "https://registry.yarnpkg.com/unicount/-/unicount-1.1.0.tgz#396a3df661c19675a93861ac878c2c9c0042abf0" integrity sha512-RlwWt1ywVW4WErPGAVHw/rIuJ2+MxvTME0siJ6lk9zBhpDfExDbspe6SRlWT3qU6AucNjotPl9qAJRVjP7guCQ== +unified@^11.0.0: + version "11.0.4" + resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.4.tgz#f4be0ac0fe4c88cb873687c07c64c49ed5969015" + integrity sha512-apMPnyLjAX+ty4OrNap7yumyVAMlKx5IWU2wlzzUdYJO9A8f1p9m/gywF/GM2ZDFcjQPrx59Mc90KwmxsoklxQ== + dependencies: + "@types/unist" "^3.0.0" + bail "^2.0.0" + devlop "^1.0.0" + extend "^3.0.0" + is-plain-obj "^4.0.0" + trough "^2.0.0" + vfile "^6.0.0" + unique-filename@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-3.0.0.tgz#48ba7a5a16849f5080d26c760c86cf5cf05770ea" @@ -10516,6 +11016,44 @@ unique-string@^2.0.0: dependencies: crypto-random-string "^2.0.0" +unist-util-is@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.0.tgz#b775956486aff107a9ded971d996c173374be424" + integrity sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-position@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4" + integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-stringify-position@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2" + integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-visit-parents@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz#4d5f85755c3b8f0dc69e21eca5d6d82d22162815" + integrity sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + +unist-util-visit@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6" + integrity sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" + universal-analytics@^0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/universal-analytics/-/universal-analytics-0.5.3.tgz#ff2d9b850062cdd4a8f652448047982a183c8e96" @@ -10664,6 +11202,31 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +vfile-location@^5.0.0: + version "5.0.2" + resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-5.0.2.tgz#220d9ca1ab6f8b2504a4db398f7ebc149f9cb464" + integrity sha512-NXPYyxyBSH7zB5U6+3uDdd6Nybz6o6/od9rk8bp9H8GR3L+cm/fC0uUTbqBmUTnMCUDslAGBOIKNfvvb+gGlDg== + dependencies: + "@types/unist" "^3.0.0" + vfile "^6.0.0" + +vfile-message@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.2.tgz#c883c9f677c72c166362fd635f21fc165a7d1181" + integrity sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" + +vfile@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.1.tgz#1e8327f41eac91947d4fe9d237a2dd9209762536" + integrity sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" + vfile-message "^4.0.0" + vite-node@0.34.6: version "0.34.6" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.34.6.tgz#34d19795de1498562bf21541a58edcd106328a17" @@ -10739,6 +11302,11 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +web-namespaces@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" + integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" @@ -11064,3 +11632,8 @@ zod@^3.22.4: version "3.22.4" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff" integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg== + +zwitch@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" + integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== From 2edefffece573bfcae1c24a66ce6bfc207e4566a Mon Sep 17 00:00:00 2001 From: Charisma Kausar Date: Sat, 11 Nov 2023 11:09:10 +0800 Subject: [PATCH 50/50] fix: make isLoading optional in difficulty selector --- frontend/src/components/common/difficulty-selector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/common/difficulty-selector.tsx b/frontend/src/components/common/difficulty-selector.tsx index cf8e426b..5f1ea278 100644 --- a/frontend/src/components/common/difficulty-selector.tsx +++ b/frontend/src/components/common/difficulty-selector.tsx @@ -7,7 +7,7 @@ interface DifficultySelectorProps { onChange: (value: Difficulty) => void; showAny: boolean; value: Difficulty; - isLoading: boolean; + isLoading?: boolean; } export default function DifficultySelector({