From efe97fb500014158c056edcd9b68dedd4fee50fa Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 10:34:13 +0800 Subject: [PATCH 01/78] Add testing for user service --- .../controller/user-controller.ts | 12 +- backend/user-service/package.json | 2 +- backend/user-service/tests/authRoutes.spec.ts | 147 ++++- backend/user-service/tests/userRoutes.spec.ts | 533 +++++++++++++++++- 4 files changed, 674 insertions(+), 20 deletions(-) diff --git a/backend/user-service/controller/user-controller.ts b/backend/user-service/controller/user-controller.ts index 893852899f..f0aeffa0d3 100644 --- a/backend/user-service/controller/user-controller.ts +++ b/backend/user-service/controller/user-controller.ts @@ -86,7 +86,7 @@ export async function createUser( }); } } catch (err) { - console.error(err); + // console.error(err); return res .status(500) .json({ message: "Unknown error when creating new user!" }); @@ -109,7 +109,7 @@ export async function getUser(req: Request, res: Response): Promise { .json({ message: `Found user`, data: formatUserResponse(user) }); } } catch (err) { - console.error(err); + // console.error(err); return res .status(500) .json({ message: "Unknown error when getting user!" }); @@ -127,7 +127,7 @@ export async function getAllUsers( .status(200) .json({ message: `Found users`, data: users.map(formatUserResponse) }); } catch (err) { - console.error(err); + // console.error(err); return res .status(500) .json({ message: "Unknown error when getting all users!" }); @@ -227,7 +227,7 @@ export async function updateUser( }); } } catch (err) { - console.error(err); + // console.error(err); return res .status(500) .json({ message: "Unknown error when updating user!" }); @@ -264,7 +264,7 @@ export async function updateUserPrivilege( return res.status(400).json({ message: "isAdmin is missing!" }); } } catch (err) { - console.error(err); + // console.error(err); return res .status(500) .json({ message: "Unknown error when updating user privilege!" }); @@ -290,7 +290,7 @@ export async function deleteUser( .status(200) .json({ message: `Deleted user ${userId} successfully` }); } catch (err) { - console.error(err); + // console.error(err); return res .status(500) .json({ message: "Unknown error when deleting user!" }); diff --git a/backend/user-service/package.json b/backend/user-service/package.json index b45de63af9..62a8afbc2b 100644 --- a/backend/user-service/package.json +++ b/backend/user-service/package.json @@ -9,7 +9,7 @@ "start": "tsx server.ts", "dev": "tsx watch server.ts", "lint": "eslint .", - "test": "export NODE_ENV=test && jest", + "test": "set NODE_ENV=test && jest", "test:watch": "export NODE_ENV=test && jest --watch" }, "keywords": [], diff --git a/backend/user-service/tests/authRoutes.spec.ts b/backend/user-service/tests/authRoutes.spec.ts index 0d53fe81f2..9644103ca3 100644 --- a/backend/user-service/tests/authRoutes.spec.ts +++ b/backend/user-service/tests/authRoutes.spec.ts @@ -10,13 +10,27 @@ const AUTH_BASE_URL = "/api/auth"; faker.seed(0); -const insertUser = async () => { - const username = faker.internet.userName(); - const firstName = faker.person.firstName(); - const lastName = faker.person.lastName(); - const email = faker.internet.email(); - const password = "strongPassword@123"; - const hashedPassword = bcrypt.hashSync(password, bcrypt.genSaltSync(10)); +const username = faker.internet.userName(); +const firstName = faker.person.firstName(); +const lastName = faker.person.lastName(); +const email = faker.internet.email(); +const password = "strongPassword@123"; +const hashedPassword = bcrypt.hashSync(password, bcrypt.genSaltSync(10)); + +const insertAdminUser = async () => { + await new UserModel({ + username, + firstName, + lastName, + email, + password: hashedPassword, + isAdmin: true, + }).save(); + + return { email, password }; +}; + +const insertNonAdminUser = async () => { await new UserModel({ username, firstName, @@ -24,13 +38,130 @@ const insertUser = async () => { email, password: hashedPassword, }).save(); + return { email, password }; }; describe("Auth routes", () => { it("Login", async () => { - const credentials = await insertUser(); + const credentials = await insertNonAdminUser(); + const res = await request.post(`${AUTH_BASE_URL}/login`).send(credentials); + expect(res.status).toBe(200); }); + + it("Login with invalid password", async () => { + const { email } = await insertNonAdminUser(); + + const res = await request + .post(`${AUTH_BASE_URL}/login`) + .send({ email, password: "blahblah" }); + + expect(res.status).toBe(401); + }); + + it("Login with invalid email", async () => { + const { password } = await insertNonAdminUser(); + + const res = await request + .post(`${AUTH_BASE_URL}/login`) + .send({ email: "blahblah", password }); + + expect(res.status).toBe(401); + }); + + it("Login with missing email and/or password", async () => { + const res = await request.post(`${AUTH_BASE_URL}/login`).send({}); + + expect(res.status).toBe(400); + }); + + it("Catch server error when login", async () => { + const loginSpy = jest.spyOn(UserModel, "findOne").mockImplementation(() => { + throw new Error(); + }); + + const res = await request + .post(`${AUTH_BASE_URL}/login`) + .send({ email, password }); + + expect(res.status).toBe(500); + + loginSpy.mockRestore(); + }); + + it("Verify token with missing token", async () => { + const res = await request.get(`${AUTH_BASE_URL}/verify-token`); + + expect(res.status).toBe(401); + }); + + it("Verify token but users not found", async () => { + // TODO + }); + + it("Verify token", async () => { + const { email, password } = await insertNonAdminUser(); + + const loginRes = await request + .post(`${AUTH_BASE_URL}/login`) + .send({ email, password }); + + const token = loginRes.body.data.accessToken; + + const res = await request + .get(`${AUTH_BASE_URL}/verify-token`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.data.email).toBe(email); + expect(res.body.data.isAdmin).toBe(false); + }); + + it("Verify invalid token", async () => { + const res = await request + .get(`${AUTH_BASE_URL}/verify-token`) + .set("Authorization", `Bearer blahblah`); + + expect(res.status).toBe(401); + }); + + it("Verify admin token", async () => { + const { email, password } = await insertAdminUser(); + + const loginRes = await request + .post(`${AUTH_BASE_URL}/login`) + .send({ email, password }); + + const token = loginRes.body.data.accessToken; + + const res = await request + .get(`${AUTH_BASE_URL}/verify-admin-token`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.data.email).toBe(email); + expect(res.body.data.isAdmin).toBe(true); + }); + + it("Verify admin token with non-admin user", async () => { + const { email, password } = await insertNonAdminUser(); + + const loginRes = await request + .post(`${AUTH_BASE_URL}/login`) + .send({ email, password }); + + const token = loginRes.body.data.accessToken; + + const res = await request + .get(`${AUTH_BASE_URL}/verify-admin-token`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(403); + }); + + it("Verify if user is owner or admin", async () => { + // TODO + }); }); diff --git a/backend/user-service/tests/userRoutes.spec.ts b/backend/user-service/tests/userRoutes.spec.ts index c54899a582..085008b413 100644 --- a/backend/user-service/tests/userRoutes.spec.ts +++ b/backend/user-service/tests/userRoutes.spec.ts @@ -1,6 +1,9 @@ +import bcrypt from "bcrypt"; +import mongoose from "mongoose"; import { faker } from "@faker-js/faker"; import supertest from "supertest"; import app from "../app"; +import UserModel from "../model/user-model"; const request = supertest(app); @@ -8,16 +11,536 @@ const USER_BASE_URL = "/api/users"; faker.seed(0); +jest.mock("../middleware/basic-access-control", () => ({ + verifyAccessToken: jest.fn((req, res, next) => { + req.user = { + id: new mongoose.Types.ObjectId().toHexString(), + username: faker.internet.userName(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + isAdmin: true, + }; + next(); + }), + + verifyIsAdmin: jest.fn((req, res, next) => { + if (req.user && req.user.isAdmin) { + next(); + } else { + res + .status(403) + .json({ message: "Not authorized to access this resource" }); + } + }), + + verifyIsOwnerOrAdmin: jest.fn((req, res, next) => { + const userIdFromReqParams = req.params.id; + const userIdFromToken = req.user.id; + + if (req.user.isAdmin || userIdFromReqParams === userIdFromToken) { + next(); + } else { + res + .status(403) + .json({ message: "Not authorized to access this resource" }); + } + }), +})); + +const username = faker.internet.userName(); +const firstName = faker.person.firstName(); +const lastName = faker.person.lastName(); +const email = faker.internet.email(); +const biography = faker.lorem.sentence(); +const password = "strongPassword@123"; +const hashedPassword = bcrypt.hashSync(password, bcrypt.genSaltSync(10)); + +const insertUser = async () => { + const user = await new UserModel({ + username, + firstName, + lastName, + email, + biography, + password: hashedPassword, + isAdmin: false, + }).save(); + return user; +}; + describe("User routes", () => { + const token: string = "token"; + it("Create a user", async () => { - const username = faker.internet.userName(); - const firstName = faker.person.firstName(); - const lastName = faker.person.lastName(); - const email = faker.internet.email(); - const password = "strongPassword@123"; const res = await request .post(USER_BASE_URL) .send({ username, firstName, lastName, email, password }); + expect(res.status).toBe(201); }); + + it("Create a user with invalid first name", async () => { + const res = await request + .post(USER_BASE_URL) + .send({ username, firstName: "123", lastName, email, password }); + + expect(res.status).toBe(400); + }); + + it("Create a user with invalid last name", async () => { + const res = await request + .post(USER_BASE_URL) + .send({ username, firstName, lastName: "123", email, password }); + + expect(res.status).toBe(400); + }); + + it("Create a user with very long name", async () => { + const res = await request.post(USER_BASE_URL).send({ + username, + firstName: faker.lorem.sentence(300), + lastName: faker.lorem.sentence(300), + email, + password, + }); + + expect(res.status).toBe(400); + }); + + it("Create a user with invalid username length", async () => { + const res = await request + .post(USER_BASE_URL) + .send({ username: "123", firstName, lastName, email, password }); + + expect(res.status).toBe(400); + }); + + it("Create a user with invalid username characters", async () => { + const res = await request + .post(USER_BASE_URL) + .send({ + username: "!!!!!!!!!!!!!", + firstName, + lastName, + email, + password, + }); + + expect(res.status).toBe(400); + }); + + it("Create a user with invalid password length", async () => { + const res = await request + .post(USER_BASE_URL) + .send({ username, firstName, lastName, email, password: "weakPw" }); + + expect(res.status).toBe(400); + }); + + it("Create a user password with no lowercase character", async () => { + const res = await request + .post(USER_BASE_URL) + .send({ username, firstName, lastName, email, password: "WEAKPW@123" }); + + expect(res.status).toBe(400); + }); + + it("Create a user password with no uppercase character", async () => { + const res = await request + .post(USER_BASE_URL) + .send({ username, firstName, lastName, email, password: "weakpw@123" }); + + expect(res.status).toBe(400); + }); + + it("Create a user password with no digit", async () => { + const res = await request + .post(USER_BASE_URL) + .send({ username, firstName, lastName, email, password: "weakPw@abc" }); + + expect(res.status).toBe(400); + }); + + it("Create a user password with no special character", async () => { + const res = await request + .post(USER_BASE_URL) + .send({ username, firstName, lastName, email, password: "weakPw123" }); + + expect(res.status).toBe(400); + }); + + it("Create a user with invalid email", async () => { + const res = await request.post(USER_BASE_URL).send({ + username, + firstName, + lastName, + email: "invalidEmail!", + password, + }); + + expect(res.status).toBe(400); + }); + + it("Create a user that already exists", async () => { + await insertUser(); + + const res = await request + .post(USER_BASE_URL) + .send({ username, firstName, lastName, email, password }); + expect(res.status).toBe(409); + }); + + it("Create a user with missing fields", async () => { + const res = await request.post(USER_BASE_URL).send({ username }); + + expect(res.status).toBe(400); + }); + + it("Catch unknown error when creating a user", async () => { + const createUserSpy = jest + .spyOn(UserModel.prototype, "save") + .mockImplementation(() => { + throw new Error(); + }); + + const res = await request + .post(USER_BASE_URL) + .send({ username, firstName, lastName, email, password }); + + expect(res.status).toBe(500); + + createUserSpy.mockRestore(); + }); + + it("Get a user", async () => { + const user = await insertUser(); + + const res = await request.get(`${USER_BASE_URL}/${user.id}`); + + expect(res.status).toBe(200); + expect(res.body.data.username).toBe(username); + expect(res.body.data.firstName).toBe(firstName); + expect(res.body.data.lastName).toBe(lastName); + }); + + it("Get an invalid user id", async () => { + const res = await request.get(`${USER_BASE_URL}/blahblah`); + + expect(res.status).toBe(404); + }); + + it("Get a user not present in the database", async () => { + const res = await request.get( + `${USER_BASE_URL}/${new mongoose.Types.ObjectId().toHexString()}` + ); + + expect(res.status).toBe(404); + }); + + it("Catch unknown error when getting a user", async () => { + const findByIdSpy = jest + .spyOn(UserModel, "findById") + .mockImplementation(() => { + throw new Error(); + }); + + const user = await insertUser(); + + const res = await request.get(`${USER_BASE_URL}/${user.id}`); + + expect(res.status).toBe(500); + + findByIdSpy.mockRestore(); + }); + + it("Get all users", async () => { + await insertUser(); + + const res = await request + .get(USER_BASE_URL) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(1); + }); + + it("Catch unknown error when getting all users", async () => { + const findAllUsersSpy = jest + .spyOn(UserModel, "find") + .mockImplementation(() => { + throw new Error(); + }); + + const res = await request + .get(USER_BASE_URL) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(500); + + findAllUsersSpy.mockRestore(); + }); + + it("Update a user", async () => { + const user = await insertUser(); + + const newFirstName = faker.person.firstName(); + const newLastName = faker.person.lastName(); + + await request + .patch(`${USER_BASE_URL}/${user.id}`) + .set("Authorization", `Bearer ${token}`) + .send({ + firstName: newFirstName, + lastName: newLastName, + biography: faker.lorem.sentence(), + }); + + const updatedUser = await UserModel.findById(user.id); + + expect(updatedUser).not.toBeNull(); + expect(updatedUser!.firstName).toBe(newFirstName); + expect(updatedUser!.lastName).toBe(newLastName); + }); + + it("Update an invalid user", async () => { + const res = await request + .patch(`${USER_BASE_URL}/blahblah`) + .set("Authorization", `Bearer ${token}`) + .send({ + firstName: faker.person.firstName(), + }); + + expect(res.status).toBe(404); + }); + + it("Update a user not present in the database", async () => { + const res = await request + .patch(`${USER_BASE_URL}/${new mongoose.Types.ObjectId().toHexString()}`) + .set("Authorization", `Bearer ${token}`) + .send({ + firstName: faker.person.firstName(), + }); + + expect(res.status).toBe(404); + }); + + it("Update a user with invalid old password", async () => { + const user = await insertUser(); + + const res = await request + .patch(`${USER_BASE_URL}/${user.id}`) + .set("Authorization", `Bearer ${token}`) + .send({ + oldPassword: "blahblah", + newPassword: "strongPassword@1234", + }); + + expect(res.status).toBe(403); + }); + + it("Update a user with invalid new password", async () => { + const user = await insertUser(); + + const res = await request + .patch(`${USER_BASE_URL}/${user.id}`) + .set("Authorization", `Bearer ${token}`) + .send({ + oldPassword: password, + newPassword: "weakPw", + }); + + expect(res.status).toBe(400); + }); + + it("Update a user with invalid first name", async () => { + const user = await insertUser(); + + const res = await request + .patch(`${USER_BASE_URL}/${user.id}`) + .set("Authorization", `Bearer ${token}`) + .send({ + firstName: "123", + }); + + expect(res.status).toBe(400); + }); + + it("Update a user with invalid last name", async () => { + const user = await insertUser(); + + const res = await request + .patch(`${USER_BASE_URL}/${user.id}`) + .set("Authorization", `Bearer ${token}`) + .send({ + lastName: "123", + }); + + expect(res.status).toBe(400); + }); + + it("Update a user with invalid biography", async () => { + const user = await insertUser(); + + const res = await request + .patch(`${USER_BASE_URL}/${user.id}`) + .set("Authorization", `Bearer ${token}`) + .send({ + biography: faker.lorem.sentence(300), + }); + + expect(res.status).toBe(400); + }); + + it("Update a user without updating any fields", async () => { + const user = await insertUser(); + + const res = await request + .patch(`${USER_BASE_URL}/${user.id}`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(400); + }); + + it("Catch unknown error when updating a user", async () => { + const findByIdAndDeleteSpy = jest + .spyOn(UserModel, "findByIdAndUpdate") + .mockImplementation(() => { + throw new Error(); + }); + + const user = await insertUser(); + + const res = await request + .patch(`${USER_BASE_URL}/${user.id}`) + .set("Authorization", `Bearer ${token}`) + .send({ + firstName: faker.person.firstName(), + }); + + expect(res.status).toBe(500); + + findByIdAndDeleteSpy.mockRestore(); + }); + + it("Update a user's privilege", async () => { + const user = await insertUser(); + + const res = await request + .patch(`${USER_BASE_URL}/${user.id}/privilege`) + .set("Authorization", `Bearer ${token}`) + .send({ + isAdmin: true, + }); + + expect(res.status).toBe(200); + + const updatedUser = await UserModel.findById(user.id); + + expect(updatedUser).not.toBeNull(); + expect(updatedUser!.isAdmin).toBe(true); + }); + + it("Update an invalid user id privilege", async () => { + const res = await request + .patch(`${USER_BASE_URL}/blahblah/privilege`) + .set("Authorization", `Bearer ${token}`) + .send({ + isAdmin: true, + }); + + expect(res.status).toBe(404); + }); + + it("Update a user's privilege whose user id is not in the database", async () => { + const res = await request + .patch( + `${USER_BASE_URL}/${new mongoose.Types.ObjectId().toHexString()}/privilege` + ) + .set("Authorization", `Bearer ${token}`) + .send({ + isAdmin: true, + }); + + expect(res.status).toBe(404); + }); + + it("Update a user's privilege without isAdmin field", async () => { + const user = await insertUser(); + + const res = await request + .patch(`${USER_BASE_URL}/${user.id}/privilege`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(400); + }); + + it("Catch unknown error when updating a user's privilege", async () => { + const findByIdAndUpdateSpy = jest + .spyOn(UserModel, "findByIdAndUpdate") + .mockImplementation(() => { + throw new Error(); + }); + + const user = await insertUser(); + + const res = await request + .patch(`${USER_BASE_URL}/${user.id}/privilege`) + .set("Authorization", `Bearer ${token}`) + .send({ + isAdmin: true, + }); + + expect(res.status).toBe(500); + + findByIdAndUpdateSpy.mockRestore(); + }); + + it("Delete a user", async () => { + const user = await insertUser(); + + const res = await request + .delete(`${USER_BASE_URL}/${user.id}`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + + const deletedUser = await UserModel.findById(user.id); + + expect(deletedUser).toBeNull(); + }); + + it("Delete an invalid user id", async () => { + const res = await request + .delete(`${USER_BASE_URL}/blahblah`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(404); + }); + + it("Delete a user not present in the database", async () => { + const res = await request + .delete(`${USER_BASE_URL}/${new mongoose.Types.ObjectId().toHexString()}`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(404); + }); + + it("Catch unknown error when deleting a user", async () => { + const findByIdAndDeleteSpy = jest + .spyOn(UserModel, "findByIdAndDelete") + .mockImplementation(() => { + throw new Error(); + }); + + const user = await insertUser(); + + const res = await request + .delete(`${USER_BASE_URL}/${user.id}`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(500); + + findByIdAndDeleteSpy.mockRestore(); + }); }); From d70696ad9c439ea628f0fb141422de9958129aab Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 10:37:14 +0800 Subject: [PATCH 02/78] Fix lint --- .../controller/user-controller.ts | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/backend/user-service/controller/user-controller.ts b/backend/user-service/controller/user-controller.ts index f0aeffa0d3..654f2fd104 100644 --- a/backend/user-service/controller/user-controller.ts +++ b/backend/user-service/controller/user-controller.ts @@ -73,7 +73,7 @@ export async function createUser( lastName, username, email, - hashedPassword, + hashedPassword ); return res.status(201).json({ message: `Created new user ${username} successfully`, @@ -85,8 +85,7 @@ export async function createUser( "At least one of first name, last name, username, email and password are missing", }); } - } catch (err) { - // console.error(err); + } catch { return res .status(500) .json({ message: "Unknown error when creating new user!" }); @@ -108,8 +107,7 @@ export async function getUser(req: Request, res: Response): Promise { .status(200) .json({ message: `Found user`, data: formatUserResponse(user) }); } - } catch (err) { - // console.error(err); + } catch { return res .status(500) .json({ message: "Unknown error when getting user!" }); @@ -126,8 +124,7 @@ export async function getAllUsers( return res .status(200) .json({ message: `Found users`, data: users.map(formatUserResponse) }); - } catch (err) { - // console.error(err); + } catch { return res .status(500) .json({ message: "Unknown error when getting all users!" }); @@ -226,8 +223,7 @@ export async function updateUser( "No field to update. Update one of the following fields: username, email, password, profilePictureUrl, firstName, lastName, biography", }); } - } catch (err) { - // console.error(err); + } catch { return res .status(500) .json({ message: "Unknown error when updating user!" }); @@ -263,8 +259,7 @@ export async function updateUserPrivilege( } else { return res.status(400).json({ message: "isAdmin is missing!" }); } - } catch (err) { - // console.error(err); + } catch { return res .status(500) .json({ message: "Unknown error when updating user privilege!" }); @@ -289,8 +284,7 @@ export async function deleteUser( return res .status(200) .json({ message: `Deleted user ${userId} successfully` }); - } catch (err) { - // console.error(err); + } catch { return res .status(500) .json({ message: "Unknown error when deleting user!" }); From f31320b8711e1af5257498f11f100dd4cbdf7e7a Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 10:39:01 +0800 Subject: [PATCH 03/78] Update .github --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2940f9a923..ae2dcd606f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,6 @@ jobs: - name: Linting working-directory: ${{ matrix.service }} run: npm run lint - # - name: Tests - # working-directory: ${{ matrix.service }} - # run: npm test + - name: Tests + working-directory: ${{ matrix.service }} + run: npm test From 463d8f71127ee14d045334c35a902d978f63f84f Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 10:59:28 +0800 Subject: [PATCH 04/78] test ci --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae2dcd606f..0ad6bd3c02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ on: env: NODE_VERSION: 20 + FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} + FIREBASE_PRIVATE_KEY: ${{ vars.FIREBASE_PRIVATE_KEY }} + FIREBASE_CLIENT_EMAIL: ${{ vars.FIREBASE_CLIENT_EMAIL }} + FIREBASE_DATABASE_URL: ${{ vars.FIREBASE_DATABASE_URL }} jobs: ci: From 0e9f2c3d4a52a240fca34275aa399016ca850247 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:04:48 +0800 Subject: [PATCH 05/78] test ci --- backend/question-service/config/firebase.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/question-service/config/firebase.ts b/backend/question-service/config/firebase.ts index 5ff9b8b053..1eaa2da11f 100644 --- a/backend/question-service/config/firebase.ts +++ b/backend/question-service/config/firebase.ts @@ -1,5 +1,8 @@ import admin from "firebase-admin"; +console.log(process.env.FIREBASE_PROJECT_ID); +console.log(process.env); + admin.initializeApp({ credential: admin.credential.cert({ projectId: process.env.FIREBASE_PROJECT_ID, From 23a7c7583e4defaad4f4029343bb45e10291be74 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:11:33 +0800 Subject: [PATCH 06/78] test ci --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ad6bd3c02..632d29242e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,8 @@ jobs: - name: Tests working-directory: ${{ matrix.service }} run: npm test + env: + FIREBASE_PROJECT_ID: ${{ env.FIREBASE_PROJECT_ID }} + FIREBASE_PRIVATE_KEY: ${{ env.FIREBASE_PRIVATE_KEY }} + FIREBASE_CLIENT_EMAIL: ${{ env.FIREBASE_CLIENT_EMAIL }} + FIREBASE_DATABASE_URL: ${{ env.FIREBASE_DATABASE_URL }} From 67de2c55205ba6a875af176c850d4b462c101c99 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:14:05 +0800 Subject: [PATCH 07/78] test ci --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 632d29242e..f86fa1ad00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: working-directory: ${{ matrix.service }} run: npm test env: - FIREBASE_PROJECT_ID: ${{ env.FIREBASE_PROJECT_ID }} - FIREBASE_PRIVATE_KEY: ${{ env.FIREBASE_PRIVATE_KEY }} - FIREBASE_CLIENT_EMAIL: ${{ env.FIREBASE_CLIENT_EMAIL }} - FIREBASE_DATABASE_URL: ${{ env.FIREBASE_DATABASE_URL }} + FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} + FIREBASE_PRIVATE_KEY: ${{ vars.FIREBASE_PRIVATE_KEY }} + FIREBASE_CLIENT_EMAIL: ${{ vars.FIREBASE_CLIENT_EMAIL }} + FIREBASE_DATABASE_URL: ${{ vars.FIREBASE_DATABASE_URL }} From 84bee1361814a3728ce17e4ea7d6542de57d7bf2 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:18:59 +0800 Subject: [PATCH 08/78] test ci --- .github/workflows/ci.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f86fa1ad00..8999961ff9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,9 +36,4 @@ jobs: run: npm run lint - name: Tests working-directory: ${{ matrix.service }} - run: npm test - env: - FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} - FIREBASE_PRIVATE_KEY: ${{ vars.FIREBASE_PRIVATE_KEY }} - FIREBASE_CLIENT_EMAIL: ${{ vars.FIREBASE_CLIENT_EMAIL }} - FIREBASE_DATABASE_URL: ${{ vars.FIREBASE_DATABASE_URL }} + run: FIREBASE_PROJECT_ID=${{env.FIREBASE_PROJECT_ID}} FIREBASE_PRIVATE_KEY=${{env.FIREBASE_PRIVATE_KEY}} FIREBASE_PRIVATE_KEY=${{env.FIREBASE_PRIVATE_KEY}} FIREBASE_DATABASE_URL=${{FIREBASE_DATABASE_URL}} npm test \ No newline at end of file From 7369ea3af4fb3ff5289a5e79fa2d113d79e20b93 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:21:18 +0800 Subject: [PATCH 09/78] test ci --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8999961ff9..b7a3301c7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,4 +36,4 @@ jobs: run: npm run lint - name: Tests working-directory: ${{ matrix.service }} - run: FIREBASE_PROJECT_ID=${{env.FIREBASE_PROJECT_ID}} FIREBASE_PRIVATE_KEY=${{env.FIREBASE_PRIVATE_KEY}} FIREBASE_PRIVATE_KEY=${{env.FIREBASE_PRIVATE_KEY}} FIREBASE_DATABASE_URL=${{FIREBASE_DATABASE_URL}} npm test \ No newline at end of file + run: FIREBASE_PROJECT_ID=${{env.FIREBASE_PROJECT_ID}} FIREBASE_PRIVATE_KEY=${{env.FIREBASE_PRIVATE_KEY}} FIREBASE_PRIVATE_KEY=${{env.FIREBASE_PRIVATE_KEY}} FIREBASE_DATABASE_URL=${{env.FIREBASE_DATABASE_URL}} npm test \ No newline at end of file From eec093ede4b498e8c0cf98484e09b0cabf08e487 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:24:51 +0800 Subject: [PATCH 10/78] test ci --- .github/workflows/ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7a3301c7e..6e060dcd35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,10 +10,6 @@ on: env: NODE_VERSION: 20 - FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} - FIREBASE_PRIVATE_KEY: ${{ vars.FIREBASE_PRIVATE_KEY }} - FIREBASE_CLIENT_EMAIL: ${{ vars.FIREBASE_CLIENT_EMAIL }} - FIREBASE_DATABASE_URL: ${{ vars.FIREBASE_DATABASE_URL }} jobs: ci: From c2588df2754ed86909172411ce482de4e2f0a808 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:26:04 +0800 Subject: [PATCH 11/78] test ci --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e060dcd35..94341ca11c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,4 +32,4 @@ jobs: run: npm run lint - name: Tests working-directory: ${{ matrix.service }} - run: FIREBASE_PROJECT_ID=${{env.FIREBASE_PROJECT_ID}} FIREBASE_PRIVATE_KEY=${{env.FIREBASE_PRIVATE_KEY}} FIREBASE_PRIVATE_KEY=${{env.FIREBASE_PRIVATE_KEY}} FIREBASE_DATABASE_URL=${{env.FIREBASE_DATABASE_URL}} npm test \ No newline at end of file + run: FIREBASE_PROJECT_ID=${{vars.FIREBASE_PROJECT_ID}} FIREBASE_PRIVATE_KEY=${{vars.FIREBASE_PRIVATE_KEY}} FIREBASE_PRIVATE_KEY=${{vars.FIREBASE_PRIVATE_KEY}} FIREBASE_DATABASE_URL=${{vars.FIREBASE_DATABASE_URL}} npm test \ No newline at end of file From 39f91b808e887897aa833cd6abcb2a886573a6d7 Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Tue, 1 Oct 2024 11:30:34 +0800 Subject: [PATCH 12/78] Test ci --- .github/workflows/ci.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2940f9a923..495d5f198e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ on: env: NODE_VERSION: 20 + FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} + FIREBASE_PRIVATE_KEY: ${{ vars.FIREBASE_PRIVATE_KEY }} + FIREBASE_CLIENT_EMAIL: ${{ vars.FIREBASE_CLIENT_EMAIL }} jobs: ci: @@ -30,6 +33,13 @@ jobs: - name: Linting working-directory: ${{ matrix.service }} run: npm run lint - # - name: Tests - # working-directory: ${{ matrix.service }} - # run: npm test + - name: Set .env + working-directory: ${{ matrix.service }} + run: | + touch .env + echo "FIREBASE_FIREBASE_PROJECT_ID=${{ env.FIREBASE_PROJECT_ID }}" >> .env + echo "FIREBASE_PRIVATE_KEY=${{ env.FIREBASE_PRIVATE_KEY }}" >> .env + echo "FIREBASE_CLIENT_EMAIL=${{ env.FIREBASE_CLIENT_EMAIL }}" >> .env + - name: Tests + working-directory: ${{ matrix.service }} + run: npm test From 905c3dd369c3ec0df31990f1d57d9c24e1763d59 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:30:39 +0800 Subject: [PATCH 13/78] test ci --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94341ca11c..fd0280c4a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,4 +32,9 @@ jobs: run: npm run lint - name: Tests working-directory: ${{ matrix.service }} - run: FIREBASE_PROJECT_ID=${{vars.FIREBASE_PROJECT_ID}} FIREBASE_PRIVATE_KEY=${{vars.FIREBASE_PRIVATE_KEY}} FIREBASE_PRIVATE_KEY=${{vars.FIREBASE_PRIVATE_KEY}} FIREBASE_DATABASE_URL=${{vars.FIREBASE_DATABASE_URL}} npm test \ No newline at end of file + run: | + FIREBASE_PROJECT_ID=${{ vars.FIREBASE_PROJECT_ID }} \ + FIREBASE_PRIVATE_KEY=${{ vars.FIREBASE_PRIVATE_KEY }} \ + FIREBASE_CLIENT_EMAIL=${{ vars.FIREBASE_CLIENT_EMAIL }} \ + FIREBASE_DATABASE_URL=${{ vars.FIREBASE_DATABASE_URL }} \ + npm test \ No newline at end of file From 7e4f8ce98f349038f55b535a5f652a89cf1dbe4c Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Tue, 1 Oct 2024 11:32:32 +0800 Subject: [PATCH 14/78] Test ci --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 495d5f198e..5da7061d15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: working-directory: ${{ matrix.service }} run: | touch .env - echo "FIREBASE_FIREBASE_PROJECT_ID=${{ env.FIREBASE_PROJECT_ID }}" >> .env + echo "FIREBASE_PROJECT_ID=${{ env.FIREBASE_PROJECT_ID }}" >> .env echo "FIREBASE_PRIVATE_KEY=${{ env.FIREBASE_PRIVATE_KEY }}" >> .env echo "FIREBASE_CLIENT_EMAIL=${{ env.FIREBASE_CLIENT_EMAIL }}" >> .env - name: Tests From 1ec3f5dddaec0c7b71a65c3ec0d86d6c408c20d5 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:34:06 +0800 Subject: [PATCH 15/78] test ci --- .github/workflows/ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd0280c4a7..484f987320 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,8 @@ on: env: NODE_VERSION: 20 + FIREBASE_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }} + FIREBASE_TEST_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }} jobs: ci: @@ -33,8 +35,8 @@ jobs: - name: Tests working-directory: ${{ matrix.service }} run: | - FIREBASE_PROJECT_ID=${{ vars.FIREBASE_PROJECT_ID }} \ - FIREBASE_PRIVATE_KEY=${{ vars.FIREBASE_PRIVATE_KEY }} \ - FIREBASE_CLIENT_EMAIL=${{ vars.FIREBASE_CLIENT_EMAIL }} \ - FIREBASE_DATABASE_URL=${{ vars.FIREBASE_DATABASE_URL }} \ + FIREBASE_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }} \ + FIREBASE_PRIVATE_KEY=${{ secrets.FIREBASE_PRIVATE_KEY }} \ + FIREBASE_CLIENT_EMAIL=${{ secrets.FIREBASE_CLIENT_EMAIL }} \ + FIREBASE_DATABASE_URL=${{ secrets.FIREBASE_DATABASE_URL }} \ npm test \ No newline at end of file From b920b57e1b3bd73d70828278af5534bcccd9392c Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:35:03 +0800 Subject: [PATCH 16/78] test ci --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 484f987320..e07f4baf47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,6 @@ on: env: NODE_VERSION: 20 - FIREBASE_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }} - FIREBASE_TEST_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }} jobs: ci: From 9ad075a7627edf620a0874b4f21a943566b27213 Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Tue, 1 Oct 2024 11:35:58 +0800 Subject: [PATCH 17/78] Test ci --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5da7061d15..f24f836026 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,13 +33,16 @@ jobs: - name: Linting working-directory: ${{ matrix.service }} run: npm run lint - - name: Set .env + - name: Set .env variables working-directory: ${{ matrix.service }} run: | touch .env echo "FIREBASE_PROJECT_ID=${{ env.FIREBASE_PROJECT_ID }}" >> .env echo "FIREBASE_PRIVATE_KEY=${{ env.FIREBASE_PRIVATE_KEY }}" >> .env echo "FIREBASE_CLIENT_EMAIL=${{ env.FIREBASE_CLIENT_EMAIL }}" >> .env + - name: Debug + working-directory: ${{ matrix.service }} + run: cat .env - name: Tests working-directory: ${{ matrix.service }} run: npm test From 1b6aa857982f6341c0f785998d097ccd1b615f86 Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Tue, 1 Oct 2024 11:39:09 +0800 Subject: [PATCH 18/78] Test ci --- .github/workflows/ci.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f24f836026..d1b475ca36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,6 @@ on: env: NODE_VERSION: 20 - FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} - FIREBASE_PRIVATE_KEY: ${{ vars.FIREBASE_PRIVATE_KEY }} - FIREBASE_CLIENT_EMAIL: ${{ vars.FIREBASE_CLIENT_EMAIL }} jobs: ci: @@ -37,9 +34,9 @@ jobs: working-directory: ${{ matrix.service }} run: | touch .env - echo "FIREBASE_PROJECT_ID=${{ env.FIREBASE_PROJECT_ID }}" >> .env - echo "FIREBASE_PRIVATE_KEY=${{ env.FIREBASE_PRIVATE_KEY }}" >> .env - echo "FIREBASE_CLIENT_EMAIL=${{ env.FIREBASE_CLIENT_EMAIL }}" >> .env + echo "FIREBASE_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }}" >> .env + echo "FIREBASE_PRIVATE_KEY=${{ secrets.FIREBASE_PRIVATE_KEY }}" >> .env + echo "FIREBASE_CLIENT_EMAIL=${{ secrets.FIREBASE_CLIENT_EMAIL }}" >> .env - name: Debug working-directory: ${{ matrix.service }} run: cat .env From dd63de1b3e3284b7212d98f617679276ced6372a Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:39:51 +0800 Subject: [PATCH 19/78] test ci --- .github/workflows/ci.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e07f4baf47..7a86202869 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,9 @@ on: branches: - "*" +permissions: + secrets: write + env: NODE_VERSION: 20 @@ -32,9 +35,9 @@ jobs: run: npm run lint - name: Tests working-directory: ${{ matrix.service }} - run: | - FIREBASE_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }} \ - FIREBASE_PRIVATE_KEY=${{ secrets.FIREBASE_PRIVATE_KEY }} \ - FIREBASE_CLIENT_EMAIL=${{ secrets.FIREBASE_CLIENT_EMAIL }} \ - FIREBASE_DATABASE_URL=${{ secrets.FIREBASE_DATABASE_URL }} \ - npm test \ No newline at end of file + env: + FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} + FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }} + FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }} + FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }} + run: npm test \ No newline at end of file From 20b6b99c3814c402fd9b066fe428803574cde5c0 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:41:15 +0800 Subject: [PATCH 20/78] test ci --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a86202869..61dd377a1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,9 +8,6 @@ on: branches: - "*" -permissions: - secrets: write - env: NODE_VERSION: 20 From 02409807c2e06a1d6b066620bb4c062bcf91e415 Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Tue, 1 Oct 2024 11:42:25 +0800 Subject: [PATCH 21/78] Test ci --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1b475ca36..fd888b168b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,8 @@ jobs: echo "FIREBASE_CLIENT_EMAIL=${{ secrets.FIREBASE_CLIENT_EMAIL }}" >> .env - name: Debug working-directory: ${{ matrix.service }} - run: cat .env + run: | + echo "FIREBASE_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }}" - name: Tests working-directory: ${{ matrix.service }} run: npm test From c371e41326299bf694f6f3926596ec1d3d242112 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:45:03 +0800 Subject: [PATCH 22/78] test ci --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61dd377a1c..7a2f1dbc4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,8 +33,10 @@ jobs: - name: Tests working-directory: ${{ matrix.service }} env: + CI: true FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }} FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }} FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }} + FIREBASE_TEST: blah run: npm test \ No newline at end of file From 421d74067736b34863685e9359133f28bf6b9566 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:48:42 +0800 Subject: [PATCH 23/78] test ci --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a2f1dbc4f..f145105ade 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,9 @@ on: env: NODE_VERSION: 20 +permissions: + contents: read + jobs: ci: runs-on: ubuntu-latest From f1a99c0c509551dd77f4ca65aa6703008cd311b4 Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Tue, 1 Oct 2024 11:52:33 +0800 Subject: [PATCH 24/78] Test ci --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd888b168b..d0175fd898 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ env: jobs: ci: runs-on: ubuntu-latest + environment: test-env strategy: matrix: service: [frontend, backend/question-service, backend/user-service] From c011c39eb6defb2adfc10df0dd18dcf8064d0503 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 12:01:42 +0800 Subject: [PATCH 25/78] test ci --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f145105ade..24eb0cf852 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,13 @@ jobs: - name: Linting working-directory: ${{ matrix.service }} run: npm run lint + - name: 'Create env file' + run: | + touch .env + echo TEST_VAR=blah >> .env + echo FIREBASE_PROJECT_ID="https://xxx.execute-api.us-west-2.amazonaws.com" >> .env + echo API_KEY=${{ secrets.API_KEY }} >> .env + cat .env - name: Tests working-directory: ${{ matrix.service }} env: From b28c409831b351713a98a95dcb996dce4915e62e Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Tue, 1 Oct 2024 12:07:57 +0800 Subject: [PATCH 26/78] Test ci --- .github/workflows/ci.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0175fd898..2d551d5433 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,6 @@ env: jobs: ci: runs-on: ubuntu-latest - environment: test-env strategy: matrix: service: [frontend, backend/question-service, backend/user-service] @@ -33,11 +32,15 @@ jobs: run: npm run lint - name: Set .env variables working-directory: ${{ matrix.service }} + env: + FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} + FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }} + FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }} run: | touch .env - echo "FIREBASE_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }}" >> .env - echo "FIREBASE_PRIVATE_KEY=${{ secrets.FIREBASE_PRIVATE_KEY }}" >> .env - echo "FIREBASE_CLIENT_EMAIL=${{ secrets.FIREBASE_CLIENT_EMAIL }}" >> .env + echo "FIREBASE_PROJECT_ID=${{ env.FIREBASE_PROJECT_ID }}" >> .env + echo "FIREBASE_PRIVATE_KEY=${{ env.FIREBASE_PRIVATE_KEY }}" >> .env + echo "FIREBASE_CLIENT_EMAIL=${{ env.FIREBASE_CLIENT_EMAIL }}" >> .env - name: Debug working-directory: ${{ matrix.service }} run: | From 1ec253e8c8cd1ae9e95d0b771f463cbbdb456298 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 12:11:14 +0800 Subject: [PATCH 27/78] test ci --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24eb0cf852..89f9661721 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,9 @@ jobs: touch .env echo TEST_VAR=blah >> .env echo FIREBASE_PROJECT_ID="https://xxx.execute-api.us-west-2.amazonaws.com" >> .env - echo API_KEY=${{ secrets.API_KEY }} >> .env + echo API_KEY=${{ secrets.FIREBASE_PRIVATE_KEY }} >> .env + echo API_KEY=${{ env.FIREBASE_PRIVATE_KEY }} >> .env + echo API_KEY=${{ vars.FIREBASE_PRIVATE_KEY }} >> .env cat .env - name: Tests working-directory: ${{ matrix.service }} From 349ac62bb0bf6a44fa96f6c16ba4583c02b2dd4f Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Tue, 1 Oct 2024 12:11:43 +0800 Subject: [PATCH 28/78] Test ci --- .github/workflows/ci.yml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d551d5433..6f24c8e137 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ on: env: NODE_VERSION: 20 + FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} + FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }} + FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }} + FIREBASE_STORAGE_BUCKET: ${{ secrets.FIREBASE_STORAGE_BUCKET }} jobs: ci: @@ -32,19 +36,22 @@ jobs: run: npm run lint - name: Set .env variables working-directory: ${{ matrix.service }} - env: - FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} - FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }} - FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }} run: | touch .env echo "FIREBASE_PROJECT_ID=${{ env.FIREBASE_PROJECT_ID }}" >> .env echo "FIREBASE_PRIVATE_KEY=${{ env.FIREBASE_PRIVATE_KEY }}" >> .env echo "FIREBASE_CLIENT_EMAIL=${{ env.FIREBASE_CLIENT_EMAIL }}" >> .env + echo "FIREBASE_CLIENT_EMAIL=${{ env.FIREBASE_CLIENT_EMAIL }}" >> .env + echo "FIREBASE_STORAGE_BUCKET=${{ env.FIREBASE_STORAGE_BUCKET }}" >> .env - name: Debug working-directory: ${{ matrix.service }} run: | - echo "FIREBASE_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }}" + echo "FIREBASE_PROJECT_ID=${{ env.FIREBASE_PROJECT_ID }}" - name: Tests working-directory: ${{ matrix.service }} - run: npm test + run: | + FIREBASE_PROJECT_ID=${{ env.FIREBASE_PROJECT_ID }} \ + FIREBASE_PRIVATE_KEY=${{ env.FIREBASE_PRIVATE_KEY }} \ + FIREBASE_CLIENT_EMAIL=${{ env.FIREBASE_CLIENT_EMAIL }} \ + FIREBASE_STORAGE_BUCKET=${{ env.FIREBASE_STORAGE_BUCKET }} \ + npm test From fb968a22982d7314946fa5262e1db889ca698c39 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 12:12:01 +0800 Subject: [PATCH 29/78] test ci --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89f9661721..83b8076958 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,8 +39,8 @@ jobs: echo TEST_VAR=blah >> .env echo FIREBASE_PROJECT_ID="https://xxx.execute-api.us-west-2.amazonaws.com" >> .env echo API_KEY=${{ secrets.FIREBASE_PRIVATE_KEY }} >> .env - echo API_KEY=${{ env.FIREBASE_PRIVATE_KEY }} >> .env - echo API_KEY=${{ vars.FIREBASE_PRIVATE_KEY }} >> .env + echo API_KEY2=${{ env.FIREBASE_PRIVATE_KEY }} >> .env + echo API_KEY4=${{ vars.FIREBASE_PRIVATE_KEY }} >> .env cat .env - name: Tests working-directory: ${{ matrix.service }} From 21f7589e1c238bda6612b7c824dc3ec2a298e0d1 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 12:14:00 +0800 Subject: [PATCH 30/78] test ci --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83b8076958..853e2751a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: working-directory: ${{ matrix.service }} env: CI: true - FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} + FIREBASE_PROJECT_ID: test FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }} FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }} FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }} From 4db9f1b8a5363371f2f583618c8f25ea7691eec8 Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Tue, 1 Oct 2024 12:25:08 +0800 Subject: [PATCH 31/78] Test ci --- .github/workflows/ci.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f24c8e137..4e4573b068 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,9 +49,4 @@ jobs: echo "FIREBASE_PROJECT_ID=${{ env.FIREBASE_PROJECT_ID }}" - name: Tests working-directory: ${{ matrix.service }} - run: | - FIREBASE_PROJECT_ID=${{ env.FIREBASE_PROJECT_ID }} \ - FIREBASE_PRIVATE_KEY=${{ env.FIREBASE_PRIVATE_KEY }} \ - FIREBASE_CLIENT_EMAIL=${{ env.FIREBASE_CLIENT_EMAIL }} \ - FIREBASE_STORAGE_BUCKET=${{ env.FIREBASE_STORAGE_BUCKET }} \ - npm test + run: npm test From a3a75fcf5206999eb0e1d8d0910482428e3cb548 Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Tue, 1 Oct 2024 12:30:34 +0800 Subject: [PATCH 32/78] Update ci --- .github/workflows/ci.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e4573b068..0f9288eca5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,6 @@ on: push: branches: - "*" - pull_request: - branches: - - "*" env: NODE_VERSION: 20 @@ -43,10 +40,6 @@ jobs: echo "FIREBASE_CLIENT_EMAIL=${{ env.FIREBASE_CLIENT_EMAIL }}" >> .env echo "FIREBASE_CLIENT_EMAIL=${{ env.FIREBASE_CLIENT_EMAIL }}" >> .env echo "FIREBASE_STORAGE_BUCKET=${{ env.FIREBASE_STORAGE_BUCKET }}" >> .env - - name: Debug - working-directory: ${{ matrix.service }} - run: | - echo "FIREBASE_PROJECT_ID=${{ env.FIREBASE_PROJECT_ID }}" - name: Tests working-directory: ${{ matrix.service }} run: npm test From bbdf05ce758e333931392cde19fb83d0cdb85a51 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Tue, 1 Oct 2024 12:36:55 +0800 Subject: [PATCH 33/78] test ci --- backend/question-service/config/firebase.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/question-service/config/firebase.ts b/backend/question-service/config/firebase.ts index 1eaa2da11f..5ff9b8b053 100644 --- a/backend/question-service/config/firebase.ts +++ b/backend/question-service/config/firebase.ts @@ -1,8 +1,5 @@ import admin from "firebase-admin"; -console.log(process.env.FIREBASE_PROJECT_ID); -console.log(process.env); - admin.initializeApp({ credential: admin.credential.cert({ projectId: process.env.FIREBASE_PROJECT_ID, From 4aa397a9a4a4592d52905835c4230150ff3654ca Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Tue, 1 Oct 2024 13:48:51 +0800 Subject: [PATCH 34/78] Add dockerfile for user service and docker compose --- backend/user-service/.dockerignore | 3 ++ backend/user-service/Dockerfile | 11 +++++++ docker-compose.yml | 14 +++++++++ frontend/src/pages/QuestionList/index.tsx | 35 +++++++++++------------ 4 files changed, 44 insertions(+), 19 deletions(-) create mode 100644 backend/user-service/.dockerignore create mode 100644 backend/user-service/Dockerfile create mode 100644 docker-compose.yml diff --git a/backend/user-service/.dockerignore b/backend/user-service/.dockerignore new file mode 100644 index 0000000000..974492219e --- /dev/null +++ b/backend/user-service/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.env +tests diff --git a/backend/user-service/Dockerfile b/backend/user-service/Dockerfile new file mode 100644 index 0000000000..27e76da901 --- /dev/null +++ b/backend/user-service/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine + +WORKDIR /user-service + +COPY package*.json ./ + +RUN npm ci + +EXPOSE 3001 + +CMD ["npm", "start"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..30cf7ceb3d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + user-service: + image: peerprep/user-service + build: ./backend/user-service + env_file: ./backend/user-service/.env + ports: + - 3001:3001 + networks: + - peerprep-network + restart: always + +networks: + peerprep-network: + driver: bridge diff --git a/frontend/src/pages/QuestionList/index.tsx b/frontend/src/pages/QuestionList/index.tsx index 000d44d56c..f9eb3abc7e 100644 --- a/frontend/src/pages/QuestionList/index.tsx +++ b/frontend/src/pages/QuestionList/index.tsx @@ -66,6 +66,17 @@ const QuestionList: React.FC = () => { ); }; + const updateQuestionList = () => { + getQuestionList( + page + 1, // convert from 0-based indexing + rowsPerPage, + searchFilter, + complexityFilter, + categoryFilter, + dispatch + ); + }; + // For handling edit / delete menu const [targetQuestion, setTargetQuestion] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null); @@ -106,25 +117,11 @@ const QuestionList: React.FC = () => { toast.success(SUCCESS_QUESTION_DELETE); getQuestionCategories(dispatch); - getQuestionList( - page + 1, // convert from 0-based indexing - rowsPerPage, - searchFilter, - complexityFilter, - categoryFilter, - dispatch - ); - }; - - const updateQuestionList = () => { - getQuestionList( - page + 1, // convert from 0-based indexing - rowsPerPage, - searchFilter, - complexityFilter, - categoryFilter, - dispatch - ); + if (state.questionCount % 10 !== 1 || page === 0) { + updateQuestionList(); + } else { + setPage(page - 1); + } }; useEffect(() => { From be2c58d942767fa5dfa66e76f335de6314a1c981 Mon Sep 17 00:00:00 2001 From: feliciagan <85786249+feliciagan@users.noreply.github.com> Date: Tue, 1 Oct 2024 13:53:19 +0800 Subject: [PATCH 35/78] Containerise question service Add Dockerfile for backend question service --- backend/question-service/.dockerignore | 4 ++++ backend/question-service/Dockerfile | 13 +++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 backend/question-service/.dockerignore create mode 100644 backend/question-service/Dockerfile diff --git a/backend/question-service/.dockerignore b/backend/question-service/.dockerignore new file mode 100644 index 0000000000..7ee74d25c3 --- /dev/null +++ b/backend/question-service/.dockerignore @@ -0,0 +1,4 @@ +node_modules +tests +.env* +*.md diff --git a/backend/question-service/Dockerfile b/backend/question-service/Dockerfile new file mode 100644 index 0000000000..84cdcd9312 --- /dev/null +++ b/backend/question-service/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "start"] From 1a969681e3803b0402dd649dbde18b61a1e97b98 Mon Sep 17 00:00:00 2001 From: feliciagan <85786249+feliciagan@users.noreply.github.com> Date: Tue, 1 Oct 2024 14:11:30 +0800 Subject: [PATCH 36/78] Update docker compose to include question service --- docker-compose.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 30cf7ceb3d..30d76fc151 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,15 @@ services: networks: - peerprep-network restart: always + question-service: + image: peerprep/question-service + build: ./backend/question-service + env_file: ./backend/question-service/.env + ports: + - 3000:3000 + networks: + - peerprep-network + restart: always networks: peerprep-network: From 250475ef29c25558aa254ed745ef38a860bb3363 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Tue, 1 Oct 2024 14:45:55 +0800 Subject: [PATCH 37/78] Fix user service dockerfile --- backend/question-service/Dockerfile | 4 ++-- backend/user-service/.dockerignore | 3 ++- backend/user-service/Dockerfile | 2 ++ docker-compose.yml | 2 -- frontend/vite.config.ts | 3 +++ 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/backend/question-service/Dockerfile b/backend/question-service/Dockerfile index 84cdcd9312..0f8e144f33 100644 --- a/backend/question-service/Dockerfile +++ b/backend/question-service/Dockerfile @@ -1,10 +1,10 @@ FROM node:20-alpine -WORKDIR /app +WORKDIR /question-service COPY package*.json ./ -RUN npm install +RUN npm ci COPY . . diff --git a/backend/user-service/.dockerignore b/backend/user-service/.dockerignore index 974492219e..7ee74d25c3 100644 --- a/backend/user-service/.dockerignore +++ b/backend/user-service/.dockerignore @@ -1,3 +1,4 @@ node_modules -.env tests +.env* +*.md diff --git a/backend/user-service/Dockerfile b/backend/user-service/Dockerfile index 27e76da901..0ff78036f7 100644 --- a/backend/user-service/Dockerfile +++ b/backend/user-service/Dockerfile @@ -6,6 +6,8 @@ COPY package*.json ./ RUN npm ci +COPY . . + EXPOSE 3001 CMD ["npm", "start"] diff --git a/docker-compose.yml b/docker-compose.yml index 30d76fc151..48e3e3fc3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,6 @@ services: - 3001:3001 networks: - peerprep-network - restart: always question-service: image: peerprep/question-service build: ./backend/question-service @@ -16,7 +15,6 @@ services: - 3000:3000 networks: - peerprep-network - restart: always networks: peerprep-network: diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ba3d0846cb..680c370a8c 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,4 +5,7 @@ import svgr from "vite-plugin-svgr"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react(), svgr()], + server: { + host: true, + }, }); From 26b6c78a70a6e016877c43016f446cc24d2bff7a Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Tue, 1 Oct 2024 19:12:32 +0800 Subject: [PATCH 38/78] Add service dependency and restart policy in docker compose --- backend/question-service/package-lock.json | 106 ++++----------------- docker-compose.yml | 4 + frontend/src/pages/QuestionList/index.tsx | 4 +- 3 files changed, 26 insertions(+), 88 deletions(-) diff --git a/backend/question-service/package-lock.json b/backend/question-service/package-lock.json index edc0990cc8..8bc358df51 100644 --- a/backend/question-service/package-lock.json +++ b/backend/question-service/package-lock.json @@ -4878,21 +4878,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -5982,10 +5967,9 @@ } }, "node_modules/express": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.20.0.tgz", - "integrity": "sha512-pLdae7I6QqShF5PnNTCVn4hI91Dx0Grkn2+IAsMTgMIKuQVte2dN9PeGSSAME2FR8anOhVA62QDIUaWVfEXVLw==", - "license": "MIT", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -5999,7 +5983,7 @@ "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", @@ -6008,11 +5992,11 @@ "parseurl": "~1.3.3", "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", - "serve-static": "1.16.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -6203,13 +6187,12 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "license": "MIT", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -6220,15 +6203,6 @@ "node": ">= 0.8" } }, - "node_modules/finalhandler/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -9020,7 +8994,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -9329,12 +9302,11 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "license": "BSD-3-Clause", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -9632,54 +9604,14 @@ "license": "MIT" }, "node_modules/serve-static": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.0.tgz", - "integrity": "sha512-pDLK8zwl2eKaYrs8mrPZBJua4hMplRWJ1tIFksVC3FtBEBnl8dxgeHtsaMS8DhS9i4fLObaon6ABoc4/hQGdPA==", - "license": "MIT", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-static/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serve-static/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static/node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" diff --git a/docker-compose.yml b/docker-compose.yml index 48e3e3fc3c..9cf2414f67 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,14 +7,18 @@ services: - 3001:3001 networks: - peerprep-network + restart: on-failure question-service: image: peerprep/question-service build: ./backend/question-service env_file: ./backend/question-service/.env ports: - 3000:3000 + depends_on: + - user-service networks: - peerprep-network + restart: on-failure networks: peerprep-network: diff --git a/frontend/src/pages/QuestionList/index.tsx b/frontend/src/pages/QuestionList/index.tsx index f9eb3abc7e..218d227a66 100644 --- a/frontend/src/pages/QuestionList/index.tsx +++ b/frontend/src/pages/QuestionList/index.tsx @@ -117,7 +117,7 @@ const QuestionList: React.FC = () => { toast.success(SUCCESS_QUESTION_DELETE); getQuestionCategories(dispatch); - if (state.questionCount % 10 !== 1 || page === 0) { + if (state.questionCount % rowsPerPage !== 1 || page === 0) { updateQuestionList(); } else { setPage(page - 1); @@ -134,8 +134,10 @@ const QuestionList: React.FC = () => { } else { updateQuestionList(); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchFilter, complexityFilter, categoryFilter]); + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => updateQuestionList(), [page]); // Check if the user is admin From 1074be85f5699ea58617e5c1231000bd050d392d Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Wed, 2 Oct 2024 01:41:25 +0800 Subject: [PATCH 39/78] Refactor profile page --- frontend/package-lock.json | 17 + frontend/package.json | 1 + frontend/src/App.tsx | 10 +- .../components/ChangePasswordModal/index.tsx | 313 +++++++++------ .../src/components/EditProfileModal/index.tsx | 372 +++++++----------- .../ProfileSection/ProfileSection.test.tsx | 124 +----- .../src/components/ProfileSection/index.tsx | 42 +- frontend/src/contexts/ProfileContext.tsx | 117 ++++++ frontend/src/pages/Profile/index.tsx | 209 +++++----- 9 files changed, 583 insertions(+), 622 deletions(-) create mode 100644 frontend/src/contexts/ProfileContext.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f2a70eaf2a..1f366ed9cd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "axios": "^1.7.7", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.53.0", "react-router-dom": "^6.26.2", "react-toastify": "^10.0.5", "vite-plugin-svgr": "^4.2.0" @@ -11102,6 +11103,22 @@ "react": "^18.3.1" } }, + "node_modules/react-hook-form": { + "version": "7.53.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.0.tgz", + "integrity": "sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index e53f4ce03f..f819038096 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "axios": "^1.7.7", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.53.0", "react-router-dom": "^6.26.2", "react-toastify": "^10.0.5", "vite-plugin-svgr": "^4.2.0" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6de5f61c5b..0091ec762f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import Home from "./pages/Home"; import SignUp from "./pages/SignUp"; import LogIn from "./pages/LogIn"; import ProtectedRoutes from "./components/ProtectedRoutes"; +import ProfileContextProvider from "./contexts/ProfileContext"; function App() { return ( @@ -26,7 +27,14 @@ function App() { } /> - } /> + + + + } + /> } /> }> diff --git a/frontend/src/components/ChangePasswordModal/index.tsx b/frontend/src/components/ChangePasswordModal/index.tsx index 0dfd5c3984..77589022b8 100644 --- a/frontend/src/components/ChangePasswordModal/index.tsx +++ b/frontend/src/components/ChangePasswordModal/index.tsx @@ -1,136 +1,199 @@ -import { forwardRef, useState } from "react"; -import { Box, Button, Stack, Typography } from "@mui/material"; -import PasswordTextField from "../PasswordTextField"; -import { userClient } from "../../utils/api"; -import axios from "axios"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; import { - FAILED_PW_UPDATE_MESSAGE, - SUCCESS_PW_UPDATE_MESSAGE, -} from "../../utils/constants"; + Button, + Container, + Dialog, + DialogContent, + DialogTitle, + IconButton, + InputAdornment, + Stack, + styled, + TextField, +} from "@mui/material"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; interface ChangePasswordModalProps { - handleClose: () => void; - userId: string; - onUpdate: ( - isProfileEdit: boolean, - message: string, - isSuccess: boolean, - ) => void; + open: boolean; + onClose: () => void; + onUpdate: ({ + oldPassword, + newPassword, + }: { + oldPassword: string; + newPassword: string; + }) => void; } -const ChangePasswordModal = forwardRef< - HTMLDivElement, - ChangePasswordModalProps ->((props, ref) => { - const { handleClose, userId, onUpdate } = props; - const [currPassword, setCurrPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); +const StyledForm = styled("form")(({ theme }) => ({ + marginTop: theme.spacing(1), +})); - const [isCurrPasswordValid, setIsCurrPasswordValid] = - useState(false); - const [isNewPasswordValid, setIsNewPasswordValid] = useState(false); - const [isConfirmPasswordValid, setIsConfirmPasswordValid] = - useState(false); - - const isUpdateDisabled = !( - isCurrPasswordValid && - isNewPasswordValid && - isConfirmPasswordValid - ); - - const handleSubmit = async () => { - const accessToken = localStorage.getItem("token"); - - try { - await userClient.patch( - `/users/${userId}`, - { - oldPassword: currPassword, - newPassword: newPassword, - }, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }, - ); - handleClose(); - onUpdate(false, SUCCESS_PW_UPDATE_MESSAGE, true); - } catch (error) { - if (axios.isAxiosError(error)) { - const message = - error.response?.data.message || FAILED_PW_UPDATE_MESSAGE; - onUpdate(false, message, false); - } else { - onUpdate(false, FAILED_PW_UPDATE_MESSAGE, false); - } - } - }; +const ChangePasswordModal: React.FC = (props) => { + const { open, onClose, onUpdate } = props; + const { + register, + handleSubmit, + formState: { errors, isDirty, isValid }, + watch, + } = useForm<{ + oldPassword: string; + newPassword: string; + confirmPassword: string; + }>({ + mode: "all", + }); + const [showOldPassword, setShowOldPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); return ( - ({ - backgroundColor: theme.palette.common.white, - display: "flex", - width: 600, - flexDirection: "column", - alignItems: "center", - borderRadius: "16px", - padding: "40px", - })} - > - - Change Password - - - - - - - - - + + Change password + + + { + onUpdate({ + oldPassword: data.oldPassword, + newPassword: data.newPassword, + }); + onClose(); + })} + > + + setShowOldPassword((prev) => !prev)} + onMouseDown={(e) => e.preventDefault()} + onMouseUp={(e) => e.preventDefault()} + edge="end" + > + {showOldPassword ? : } + + + ), + }, + }} + {...register("oldPassword")} + /> + + setShowNewPassword((prev) => !prev)} + onMouseDown={(e) => e.preventDefault()} + onMouseUp={(e) => e.preventDefault()} + edge="end" + > + {showNewPassword ? : } + + + ), + }, + }} + {...register("newPassword", { + minLength: { + value: 8, + message: "Password must be at least 8 characters long", + }, + validate: { + atLeastOneLowercase: (value) => + /[a-z]/.test(value) || + "Password must contain at least 1 lowercase letter", + atLeastOneUppercase: (value) => + /[A-Z]/.test(value) || + "Password must contain at least 1 uppercase letter", + atLeastOneDigit: (value) => + /\d/.test(value) || + "Password must contain at least 1 digit", + atLeastOneSpecialCharacter: (value) => + // eslint-disable-next-line no-useless-escape + /[ `!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/.test(value) || + "Password must contain at least 1 special character", + }, + })} + error={!!errors.newPassword} + helperText={errors.newPassword?.message} + /> + + setShowConfirmPassword((prev) => !prev)} + onMouseDown={(e) => e.preventDefault()} + onMouseUp={(e) => e.preventDefault()} + edge="end" + > + {showConfirmPassword ? ( + + ) : ( + + )} + + + ), + }, + }} + {...register("confirmPassword", { + validate: { + matchPassword: (value) => + watch("newPassword") === value || "Password does not match", + }, + })} + error={!!errors.confirmPassword} + helperText={errors.confirmPassword?.message} + /> + ({ marginTop: theme.spacing(1) })} + > + + + + + + + ); -}); +}; export default ChangePasswordModal; diff --git a/frontend/src/components/EditProfileModal/index.tsx b/frontend/src/components/EditProfileModal/index.tsx index 1acedcf9e5..8ad6264bb1 100644 --- a/frontend/src/components/EditProfileModal/index.tsx +++ b/frontend/src/components/EditProfileModal/index.tsx @@ -1,255 +1,157 @@ -import { forwardRef, useState } from "react"; import { - Box, Button, - FormControl, - FormHelperText, + Container, + Dialog, + DialogContent, + DialogTitle, Stack, + styled, TextField, - Typography, } from "@mui/material"; -import { userClient } from "../../utils/api"; -import axios from "axios"; -import { - FAILED_PROFILE_UPDATE_MESSAGE, - SUCCESS_PROFILE_UPDATE_MESSAGE, -} from "../../utils/constants"; +import { useForm } from "react-hook-form"; interface EditProfileModalProps { - handleClose: () => void; + onClose: () => void; + open: boolean; currFirstName: string; currLastName: string; currBiography?: string; - userId: string; - onUpdate: ( - isProfileEdit: boolean, - message: string, - isSuccess: boolean, - ) => void; + onUpdate: ({ + firstName, + lastName, + biography, + }: { + firstName: string; + lastName: string; + biography: string; + }) => void; } -const EditProfileModal = forwardRef( - (props, ref) => { - const { - handleClose, - currFirstName, - currLastName, - currBiography, - userId, - onUpdate, - } = props; - const nameCharLimit = 50; - const bioCharLimit = 255; - const [newFirstName, setNewFirstName] = useState(currFirstName); - const [newLastName, setNewLastName] = useState(currLastName); - const [newBio, setNewBio] = useState(currBiography || ""); - - const [firstNameError, setFirstNameError] = useState(false); - const [lastNameError, setLastNameError] = useState(false); - - const checkForChanges = (): boolean => { - if ( - newFirstName != currFirstName || - newLastName != currLastName || - newBio != currBiography - ) { - return true; - } else { - return false; - } - }; +const StyledForm = styled("form")(({ theme }) => ({ + marginTop: theme.spacing(1), +})); - const isUpdateDisabled = - firstNameError || lastNameError || !checkForChanges(); +const EditProfileModal: React.FC = (props) => { + const { + open, + onClose, + currFirstName, + currLastName, + currBiography, + onUpdate, + } = props; + const nameCharLimit = 50; + const bioCharLimit = 255; - const handleSubmit = async () => { - const accessToken = localStorage.getItem("token"); + const { + register, + formState: { errors, isValid, isDirty }, + handleSubmit, + } = useForm<{ + firstName: string; + lastName: string; + biography: string; + }>({ + defaultValues: { + firstName: currFirstName, + lastName: currLastName, + biography: currBiography, + }, + mode: "all", + }); - try { - await userClient.patch( - `/users/${userId}`, - { - firstName: newFirstName, - lastName: newLastName, - biography: newBio, - }, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }, - ); - handleClose(); - onUpdate(true, SUCCESS_PROFILE_UPDATE_MESSAGE, true); - } catch (error) { - console.error("Error:", error); - if (axios.isAxiosError(error)) { - const message = - error.response?.data.message || FAILED_PROFILE_UPDATE_MESSAGE; - onUpdate(true, message, false); - } else { - onUpdate(true, FAILED_PROFILE_UPDATE_MESSAGE, false); - } - } - }; - - return ( - ({ - backgroundColor: theme.palette.common.white, - display: "flex", - width: 600, - flexDirection: "column", - alignItems: "center", - borderRadius: "16px", - padding: "40px", - })} - > - - Edit Profile - - - { - const val = input.target.value; - if (!/^[a-zA-Z\s-]*$/.test(val) || val.length == 0) { - setFirstNameError(true); - } else { - setFirstNameError(false); - } - setNewFirstName(val); - }} - error={firstNameError} - /> - {firstNameError ? ( - - {newFirstName.length == 0 ? ( - - Required field - - ) : ( - - Only alphabetical, hyphen and white space characters allowed - - )} - - {newFirstName.length} / {nameCharLimit} characters - - - ) : ( - - {newFirstName.length} / {nameCharLimit} characters - - )} - - - { - const val = input.target.value; - if (!/^[a-zA-Z\s-]*$/.test(val) || val.length == 0) { - setLastNameError(true); - } else { - setLastNameError(false); - } - setNewLastName(val); - }} - error={lastNameError} - /> - {lastNameError ? ( + return ( + + Edit profile + + + { + onUpdate(data); + onClose(); + })} + > + + + ({ marginTop: theme.spacing(1) })} > - {newLastName.length == 0 ? ( - - Required field - - ) : ( - - Only alphabetical, hyphen and white space characters allowed - - )} - - {newLastName.length} / {nameCharLimit} characters - + + - ) : ( - - {newLastName.length} / {nameCharLimit} characters - - )} - - { - setNewBio(input.target.value); - }} - helperText={newBio.length + ` / ${bioCharLimit} characters`} - /> - - - - - - ); - }, -); + + + + + ); +}; export default EditProfileModal; diff --git a/frontend/src/components/ProfileSection/ProfileSection.test.tsx b/frontend/src/components/ProfileSection/ProfileSection.test.tsx index 1bc2930666..cddf47b020 100644 --- a/frontend/src/components/ProfileSection/ProfileSection.test.tsx +++ b/frontend/src/components/ProfileSection/ProfileSection.test.tsx @@ -2,28 +2,22 @@ import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; import { faker } from "@faker-js/faker"; -import ProfileSection from "."; +import ProfileDetails from "."; faker.seed(0); -describe("Profile section", () => { +describe("Profile details", () => { it("First name and last name is rendered", () => { const firstName = faker.person.firstName(); const lastName = faker.person.lastName(); const username = faker.internet.userName(); const biography = faker.person.bio(); - const isCurrentUser = false; - const handleEditProfileOpen = jest.fn(); - const handleChangePasswordOpen = jest.fn(); render( - ); expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); @@ -34,124 +28,14 @@ describe("Profile section", () => { const lastName = faker.person.lastName(); const username = faker.internet.userName(); const biography = faker.person.bio(); - const isCurrentUser = false; - const handleEditProfileOpen = jest.fn(); - const handleChangePasswordOpen = jest.fn(); render( - ); expect(screen.getByText(`@${username}`)).toBeInTheDocument(); }); }); - -describe("Profiles that don't belong to the current authenticated user", () => { - it("Edit profile button is absent", () => { - const firstName = faker.person.firstName(); - const lastName = faker.person.lastName(); - const username = faker.internet.userName(); - const biography = faker.person.bio(); - const isCurrentUser = false; - const handleEditProfileOpen = jest.fn(); - const handleChangePasswordOpen = jest.fn(); - render( - - ); - const editProfileButton = screen.queryByRole("button", { - name: "Edit profile", - }); - expect(editProfileButton).not.toBeInTheDocument(); - }); - - it("Change password button is absent", () => { - const firstName = faker.person.firstName(); - const lastName = faker.person.lastName(); - const username = faker.internet.userName(); - const biography = faker.person.bio(); - const isCurrentUser = false; - const handleEditProfileOpen = jest.fn(); - const handleChangePasswordOpen = jest.fn(); - render( - - ); - const editProfileButton = screen.queryByRole("button", { - name: "Change password", - }); - expect(editProfileButton).not.toBeInTheDocument(); - }); -}); - -describe("Profiles that belong to the current authenticated user", () => { - it("Edit profile button is present", () => { - const firstName = faker.person.firstName(); - const lastName = faker.person.lastName(); - const username = faker.internet.userName(); - const biography = faker.person.bio(); - const isCurrentUser = true; - const handleEditProfileOpen = jest.fn(); - const handleChangePasswordOpen = jest.fn(); - render( - - ); - const editProfileButton = screen.queryByRole("button", { - name: "Edit profile", - }); - expect(editProfileButton).toBeInTheDocument(); - }); - - it("Change password button is present", () => { - const firstName = faker.person.firstName(); - const lastName = faker.person.lastName(); - const username = faker.internet.userName(); - const biography = faker.person.bio(); - const isCurrentUser = true; - const handleEditProfileOpen = jest.fn(); - const handleChangePasswordOpen = jest.fn(); - render( - - ); - const editProfileButton = screen.getByRole("button", { - name: "Change password", - }); - expect(editProfileButton).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/components/ProfileSection/index.tsx b/frontend/src/components/ProfileSection/index.tsx index 1b64ed5cb0..f8a757b5df 100644 --- a/frontend/src/components/ProfileSection/index.tsx +++ b/frontend/src/components/ProfileSection/index.tsx @@ -1,4 +1,4 @@ -import { Avatar, Box, Button, Divider, Stack, Typography } from "@mui/material"; +import { Avatar, Box, Typography } from "@mui/material"; import React from "react"; type ProfileSectionProps = { @@ -6,21 +6,10 @@ type ProfileSectionProps = { lastName: string; username: string; biography?: string; - isCurrentUser: boolean; - handleEditProfileOpen: () => void; - handleChangePasswordOpen: () => void; }; -const ProfileSection: React.FC = (props) => { - const { - firstName, - lastName, - username, - biography, - isCurrentUser, - handleEditProfileOpen, - handleChangePasswordOpen, - } = props; +const ProfileDetails: React.FC = (props) => { + const { firstName, lastName, username, biography } = props; return ( @@ -51,31 +40,8 @@ const ProfileSection: React.FC = (props) => { {biography} - {isCurrentUser && ( - <> - - ({ - marginTop: theme.spacing(4), - marginBottom: theme.spacing(4), - })} - > - - - - - )} ); }; -export default ProfileSection; +export default ProfileDetails; diff --git a/frontend/src/contexts/ProfileContext.tsx b/frontend/src/contexts/ProfileContext.tsx new file mode 100644 index 0000000000..21f9423d19 --- /dev/null +++ b/frontend/src/contexts/ProfileContext.tsx @@ -0,0 +1,117 @@ +import { createContext, useContext, useState } from "react"; +import { userClient } from "../utils/api"; +import { + FAILED_PROFILE_UPDATE_MESSAGE, + FAILED_PW_UPDATE_MESSAGE, + SUCCESS_PROFILE_UPDATE_MESSAGE, + SUCCESS_PW_UPDATE_MESSAGE, +} from "../utils/constants"; +import { toast } from "react-toastify"; + +interface UserProfileBase { + firstName: string; + lastName: string; + biography?: string; +} + +interface UserProfile extends UserProfileBase { + id: string; + username: string; + email: string; + isAdmin: boolean; + profilePictureUrl?: string; + createdAt: string; +} + +type ProfileContextType = { + user: UserProfile | null; + editProfileOpen: boolean; + passwordModalOpen: boolean; + fetchUser: (userId: string) => void; + updateProfile: (data: UserProfileBase) => void; + updatePassword: ({ + oldPassword, + newPassword, + }: { + oldPassword: string; + newPassword: string; + }) => void; + setEditProfileModalOpen: React.Dispatch>; + setPasswordModalOpen: React.Dispatch>; +}; + +const ProfileContext = createContext(undefined); + +const ProfileContextProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [user, setUser] = useState(null); + const [editProfileOpen, setEditProfileModalOpen] = useState(false); + const [passwordModalOpen, setPasswordModalOpen] = useState(false); + + const fetchUser = (userId: string) => { + userClient + .get(`/users/${userId}`) + .then((res) => setUser(res.data.data)) + .catch(() => setUser(null)); + }; + + const updateProfile = async (data: UserProfileBase) => { + const token = localStorage.getItem("token"); + await userClient + .patch(`/users/${user?.id}`, data, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((res) => { + setUser(res.data.data); + toast.success(SUCCESS_PROFILE_UPDATE_MESSAGE); + }) + .catch((err) => { + const message = + err.response.data.message || FAILED_PROFILE_UPDATE_MESSAGE; + toast.error(message); + }); + }; + + const updatePassword = async ({ + oldPassword, + newPassword, + }: { + oldPassword: string; + newPassword: string; + }) => { + const token = localStorage.getItem("token"); + await userClient + .patch( + `/users/${user?.id}`, + { oldPassword, newPassword }, + { headers: { Authorization: `Bearer ${token}` } } + ) + .then(() => toast.success(SUCCESS_PW_UPDATE_MESSAGE)) + .catch((err) => { + const message = err.response.data.message || FAILED_PW_UPDATE_MESSAGE; + toast.error(message); + }); + }; + + return ( + + {children} + + ); +}; + +export const useProfile = () => useContext(ProfileContext); + +export default ProfileContextProvider; diff --git a/frontend/src/pages/Profile/index.tsx b/frontend/src/pages/Profile/index.tsx index 081b342095..d21a44fe36 100644 --- a/frontend/src/pages/Profile/index.tsx +++ b/frontend/src/pages/Profile/index.tsx @@ -1,58 +1,69 @@ import { useParams } from "react-router-dom"; import AppMargin from "../../components/AppMargin"; -import ProfileSection from "../../components/ProfileSection"; -import { Box, Modal, Typography } from "@mui/material"; +import ProfileDetails from "../../components/ProfileSection"; +import { Box, Button, Divider, Stack, Typography } from "@mui/material"; import classes from "./index.module.css"; -import { useEffect, useState } from "react"; -import { userClient } from "../../utils/api"; +import { useEffect } from "react"; import { useAuth } from "../../contexts/AuthContext"; -import { toast } from "react-toastify"; import ServerError from "../../components/ServerError"; import EditProfileModal from "../../components/EditProfileModal"; import ChangePasswordModal from "../../components/ChangePasswordModal"; +import { useProfile } from "../../contexts/ProfileContext"; -type UserProfile = { - id: string; - username: string; - firstName: string; - lastName: string; - email: string; - isAdmin: boolean; - biography?: string; - profilePictureUrl?: string; - createdAt: string; -}; +// type UserProfile = { +// id: string; +// username: string; +// firstName: string; +// lastName: string; +// email: string; +// isAdmin: boolean; +// biography?: string; +// profilePictureUrl?: string; +// createdAt: string; +// }; const ProfilePage: React.FC = () => { - const [editProfileOpen, setEditProfileOpen] = useState(false); - const handleEditProfileOpen = () => setEditProfileOpen(true); - const handleEditProfileClose = () => setEditProfileOpen(false); - const [changePasswordOpen, setChangePasswordOpen] = useState(false); - const handleChangePasswordOpen = () => setChangePasswordOpen(true); - const handleChangePasswordClose = () => setChangePasswordOpen(false); - const [isProfileChanged, setIsProfileChanged] = useState(false); + // const [editProfileOpen, setEditProfileOpen] = useState(false); + // const handleEditProfileOpen = () => setEditProfileOpen(true); + // const handleEditProfileClose = () => setEditProfileOpen(false); + // const [changePasswordOpen, setChangePasswordOpen] = useState(false); + // const handleChangePasswordOpen = () => setChangePasswordOpen(true); + // const handleChangePasswordClose = () => setChangePasswordOpen(false); + // const [isProfileChanged, setIsProfileChanged] = useState(false); const { userId } = useParams<{ userId: string }>(); - const [userProfile, setUserProfile] = useState(null); const auth = useAuth(); + const profile = useProfile(); + if (!auth) { throw new Error("useAuth() must be used within AuthProvider"); } - const { user } = auth; + if (!profile) { + throw new Error("useProfile() must be used within ProfileContextProvider"); + } + + const { + user, + editProfileOpen, + passwordModalOpen, + fetchUser, + setEditProfileModalOpen, + setPasswordModalOpen, + updateProfile, + updatePassword, + } = profile; useEffect(() => { - userClient - .get(`/users/${userId}`) - .then((res) => { - setUserProfile(res.data.data); - }) - .catch(() => setUserProfile(null)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isProfileChanged]); + if (!userId) { + return; + } + + fetchUser(userId); + }, []); - if (!userProfile) { + if (!user) { return ( { ); } - const notify = ( - isProfileEdit: boolean, - message: string, - isSuccess: boolean, - ) => { - if (isSuccess) { - toast.success(message); - if (isProfileEdit) { - setIsProfileChanged(true); - } - } else { - toast.error(message); - } - }; + const isCurrentUser = auth.user?.id === userId; return ( - userId && ( - - ({ - marginTop: theme.spacing(4), - display: "flex", - })} - > - ({ flex: 1, paddingRight: theme.spacing(4) })}> - + ({ + marginTop: theme.spacing(4), + display: "flex", + })} + > + ({ flex: 1, paddingRight: theme.spacing(4) })}> + + + {isCurrentUser && ( + <> + + ({ + marginTop: theme.spacing(4), + marginBottom: theme.spacing(4), + })} + > + + + + + )} - ({ flex: 3, paddingLeft: theme.spacing(4) })}> - Questions attempted - - - - - - - - - ) + ({ flex: 3, paddingLeft: theme.spacing(4) })}> + Questions attempted + + setEditProfileModalOpen(false)} + currFirstName={user.firstName} + currLastName={user.lastName} + currBiography={user.biography} + onUpdate={updateProfile} + /> + setPasswordModalOpen(false)} + onUpdate={updatePassword} + /> + + ); }; From a3de987b94400851dd86b518cbc1cf38a3f85f31 Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Wed, 2 Oct 2024 09:14:08 +0800 Subject: [PATCH 40/78] Fix issues --- .../ProfileDetailstest.tsx} | 0 .../index.tsx | 0 frontend/src/pages/Profile/index.tsx | 22 +------------------ 3 files changed, 1 insertion(+), 21 deletions(-) rename frontend/src/components/{ProfileSection/ProfileSection.test.tsx => ProfileDetails/ProfileDetailstest.tsx} (100%) rename frontend/src/components/{ProfileSection => ProfileDetails}/index.tsx (100%) diff --git a/frontend/src/components/ProfileSection/ProfileSection.test.tsx b/frontend/src/components/ProfileDetails/ProfileDetailstest.tsx similarity index 100% rename from frontend/src/components/ProfileSection/ProfileSection.test.tsx rename to frontend/src/components/ProfileDetails/ProfileDetailstest.tsx diff --git a/frontend/src/components/ProfileSection/index.tsx b/frontend/src/components/ProfileDetails/index.tsx similarity index 100% rename from frontend/src/components/ProfileSection/index.tsx rename to frontend/src/components/ProfileDetails/index.tsx diff --git a/frontend/src/pages/Profile/index.tsx b/frontend/src/pages/Profile/index.tsx index d21a44fe36..779013e438 100644 --- a/frontend/src/pages/Profile/index.tsx +++ b/frontend/src/pages/Profile/index.tsx @@ -1,6 +1,6 @@ import { useParams } from "react-router-dom"; import AppMargin from "../../components/AppMargin"; -import ProfileDetails from "../../components/ProfileSection"; +import ProfileDetails from "../../components/ProfileDetails"; import { Box, Button, Divider, Stack, Typography } from "@mui/material"; import classes from "./index.module.css"; import { useEffect } from "react"; @@ -10,27 +10,7 @@ import EditProfileModal from "../../components/EditProfileModal"; import ChangePasswordModal from "../../components/ChangePasswordModal"; import { useProfile } from "../../contexts/ProfileContext"; -// type UserProfile = { -// id: string; -// username: string; -// firstName: string; -// lastName: string; -// email: string; -// isAdmin: boolean; -// biography?: string; -// profilePictureUrl?: string; -// createdAt: string; -// }; - const ProfilePage: React.FC = () => { - // const [editProfileOpen, setEditProfileOpen] = useState(false); - // const handleEditProfileOpen = () => setEditProfileOpen(true); - // const handleEditProfileClose = () => setEditProfileOpen(false); - // const [changePasswordOpen, setChangePasswordOpen] = useState(false); - // const handleChangePasswordOpen = () => setChangePasswordOpen(true); - // const handleChangePasswordClose = () => setChangePasswordOpen(false); - // const [isProfileChanged, setIsProfileChanged] = useState(false); - const { userId } = useParams<{ userId: string }>(); const auth = useAuth(); From 2903a3cd7df9e876fc6ad893e993f737aa569edd Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Wed, 2 Oct 2024 09:21:02 +0800 Subject: [PATCH 41/78] Update props --- .../components/ChangePasswordModal/index.tsx | 20 +++++++------ .../src/components/EditProfileModal/index.tsx | 29 +++++++------------ frontend/src/pages/Profile/index.tsx | 2 -- 3 files changed, 22 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/ChangePasswordModal/index.tsx b/frontend/src/components/ChangePasswordModal/index.tsx index 77589022b8..753e6ba20a 100644 --- a/frontend/src/components/ChangePasswordModal/index.tsx +++ b/frontend/src/components/ChangePasswordModal/index.tsx @@ -13,17 +13,11 @@ import { } from "@mui/material"; import { useState } from "react"; import { useForm } from "react-hook-form"; +import { useProfile } from "../../contexts/ProfileContext"; interface ChangePasswordModalProps { open: boolean; onClose: () => void; - onUpdate: ({ - oldPassword, - newPassword, - }: { - oldPassword: string; - newPassword: string; - }) => void; } const StyledForm = styled("form")(({ theme }) => ({ @@ -31,7 +25,7 @@ const StyledForm = styled("form")(({ theme }) => ({ })); const ChangePasswordModal: React.FC = (props) => { - const { open, onClose, onUpdate } = props; + const { open, onClose } = props; const { register, handleSubmit, @@ -48,6 +42,14 @@ const ChangePasswordModal: React.FC = (props) => { const [showNewPassword, setShowNewPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const profile = useProfile(); + + if (!profile) { + throw new Error("useProfile() must be used within ProfileContextProvider"); + } + + const { updatePassword } = profile; + return ( Change password @@ -55,7 +57,7 @@ const ChangePasswordModal: React.FC = (props) => { { - onUpdate({ + updatePassword({ oldPassword: data.oldPassword, newPassword: data.newPassword, }); diff --git a/frontend/src/components/EditProfileModal/index.tsx b/frontend/src/components/EditProfileModal/index.tsx index 8ad6264bb1..7d5d5a3509 100644 --- a/frontend/src/components/EditProfileModal/index.tsx +++ b/frontend/src/components/EditProfileModal/index.tsx @@ -9,6 +9,7 @@ import { TextField, } from "@mui/material"; import { useForm } from "react-hook-form"; +import { useProfile } from "../../contexts/ProfileContext"; interface EditProfileModalProps { onClose: () => void; @@ -16,15 +17,6 @@ interface EditProfileModalProps { currFirstName: string; currLastName: string; currBiography?: string; - onUpdate: ({ - firstName, - lastName, - biography, - }: { - firstName: string; - lastName: string; - biography: string; - }) => void; } const StyledForm = styled("form")(({ theme }) => ({ @@ -32,14 +24,7 @@ const StyledForm = styled("form")(({ theme }) => ({ })); const EditProfileModal: React.FC = (props) => { - const { - open, - onClose, - currFirstName, - currLastName, - currBiography, - onUpdate, - } = props; + const { open, onClose, currFirstName, currLastName, currBiography } = props; const nameCharLimit = 50; const bioCharLimit = 255; @@ -60,6 +45,14 @@ const EditProfileModal: React.FC = (props) => { mode: "all", }); + const profile = useProfile(); + + if (!profile) { + throw new Error("useProfile() must be used within ProfileContextProvider"); + } + + const { updateProfile } = profile; + return ( Edit profile @@ -67,7 +60,7 @@ const EditProfileModal: React.FC = (props) => { { - onUpdate(data); + updateProfile(data); onClose(); })} > diff --git a/frontend/src/pages/Profile/index.tsx b/frontend/src/pages/Profile/index.tsx index 779013e438..0553e8b356 100644 --- a/frontend/src/pages/Profile/index.tsx +++ b/frontend/src/pages/Profile/index.tsx @@ -109,12 +109,10 @@ const ProfilePage: React.FC = () => { currFirstName={user.firstName} currLastName={user.lastName} currBiography={user.biography} - onUpdate={updateProfile} /> setPasswordModalOpen(false)} - onUpdate={updatePassword} /> From 8bcdc805fa09bb742cb456b67fe3fcea2f627067 Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Wed, 2 Oct 2024 09:21:42 +0800 Subject: [PATCH 42/78] Remove unused code --- frontend/src/pages/Profile/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/pages/Profile/index.tsx b/frontend/src/pages/Profile/index.tsx index 0553e8b356..bc0407e409 100644 --- a/frontend/src/pages/Profile/index.tsx +++ b/frontend/src/pages/Profile/index.tsx @@ -31,8 +31,6 @@ const ProfilePage: React.FC = () => { fetchUser, setEditProfileModalOpen, setPasswordModalOpen, - updateProfile, - updatePassword, } = profile; useEffect(() => { From db15fee02f1fb4742dd7c4919a09ede3ce3c8d7d Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Wed, 2 Oct 2024 10:26:09 +0800 Subject: [PATCH 43/78] Refactor signup and login pages --- frontend/src/pages/LogIn/LogIn.test.tsx | 24 ++- frontend/src/pages/LogIn/index.tsx | 103 ++++++------ frontend/src/pages/SignUp/SignUp.test.tsx | 94 ++++++++--- frontend/src/pages/SignUp/index.tsx | 183 +++++++++++----------- frontend/src/utils/validators.ts | 8 +- 5 files changed, 231 insertions(+), 181 deletions(-) diff --git a/frontend/src/pages/LogIn/LogIn.test.tsx b/frontend/src/pages/LogIn/LogIn.test.tsx index 5cb5a48b70..d417c05d6f 100644 --- a/frontend/src/pages/LogIn/LogIn.test.tsx +++ b/frontend/src/pages/LogIn/LogIn.test.tsx @@ -17,7 +17,9 @@ jest.mock("../../utils/api", () => ({ post: jest.fn(), }, })); -const mockedPost = userClient.post as jest.MockedFunction; +const mockedPost = userClient.post as jest.MockedFunction< + typeof userClient.post +>; describe("Log In Components", () => { beforeEach(() => { @@ -36,12 +38,12 @@ describe("Log In Components", () => { it("Email field is rendered", () => { render(); - expect(screen.getByTestId("Email")).toBeInTheDocument(); + expect(screen.getByLabelText(/Email/)).toBeInTheDocument(); }); it("Password field is rendered", () => { render(); - expect(screen.getByTestId("Password")).toBeInTheDocument(); + expect(screen.getByLabelText(/Password/)).toBeInTheDocument(); }); it("Log in button is rendered", () => { @@ -83,8 +85,12 @@ describe("Log In Events", () => { render(); - fireEvent.change(screen.getByTestId("Email"), { target: { value: email } }); - fireEvent.change(screen.getByTestId("Password"), { target: { value: password } }); + fireEvent.change(screen.getByLabelText(/Email/), { + target: { value: email }, + }); + fireEvent.change(screen.getByLabelText(/Password/), { + target: { value: password }, + }); fireEvent.click(screen.getByRole("button", { name: "Log in" })); await waitFor(() => { @@ -100,8 +106,12 @@ describe("Log In Events", () => { render(); - fireEvent.change(screen.getByTestId("Email"), { target: { value: invalidEmail } }); - fireEvent.change(screen.getByTestId("Password"), { target: { value: password } }); + fireEvent.change(screen.getByLabelText(/Email/), { + target: { value: invalidEmail }, + }); + fireEvent.change(screen.getByLabelText(/Password/), { + target: { value: password }, + }); fireEvent.click(screen.getByRole("button", { name: "Log in" })); await waitFor(() => { diff --git a/frontend/src/pages/LogIn/index.tsx b/frontend/src/pages/LogIn/index.tsx index 80342173f0..b6efe6c53d 100644 --- a/frontend/src/pages/LogIn/index.tsx +++ b/frontend/src/pages/LogIn/index.tsx @@ -1,52 +1,36 @@ -import { Box, Button, Stack, Typography } from "@mui/material"; +import { + Box, + Button, + IconButton, + InputAdornment, + Stack, + TextField, + Typography, +} from "@mui/material"; import LogInSvg from "../../assets/login.svg?react"; -import { useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../../contexts/AuthContext"; -import CustomTextField from "../../components/CustomTextField"; -import { emailValidator } from "../../utils/validators"; +import { emailValidator, passwordValidator } from "../../utils/validators"; import { ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; +import { useForm } from "react-hook-form"; +import { useState } from "react"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; const LogIn: React.FC = () => { const navigate = useNavigate(); const auth = useAuth(); + const [showPassword, setShowPassword] = useState(false); if (!auth) { throw new Error("useAuth() must be used within AuthProvider"); } const { login } = auth; - const formValues = useRef({ email: "", password: "" }); - const formValidity = useRef({ email: false, password: false }); - const [emptyFields, setEmptyFields] = useState<{ [key: string]: boolean }>({ - email: false, - password: false, - }); - - const handleInputChange = ( - field: keyof typeof formValues.current, - value: string, - isValid: boolean, - ) => { - formValues.current[field] = value; - formValidity.current[field] = isValid; - setEmptyFields((prevState) => ({ ...prevState, [field]: !value })); - }; - - const handleLogIn = (event: React.FormEvent) => { - event.preventDefault(); - - if (!Object.values(formValidity.current).every((isValid) => isValid)) { - // Mark untouched required fields red - Object.entries(formValues.current).forEach(([field, value]) => { - setEmptyFields((prevState) => ({ ...prevState, [field]: !value })); - }); - return; - } - - const { email, password } = formValues.current; - login(email, password); - }; + const { + register, + handleSubmit, + formState: { errors }, + } = useForm<{ email: string; password: string }>({ mode: "all" }); return ( { ({ marginTop: theme.spacing(2), marginBottom: theme.spacing(2), })} - onSubmit={handleLogIn} + onSubmit={handleSubmit((data) => login(data.email, data.password))} noValidate > - - handleInputChange("email", value, isValid) - } + fullWidth + margin="normal" + type="email" + {...register("email", { validate: { emailValidator } })} + error={!!errors.email} + helperText={errors.email?.message} /> - - handleInputChange("password", value, isValid) - } - isPasswordField + fullWidth + margin="normal" + {...register("password", { validate: { passwordValidator } })} + error={!!errors.password} + helperText={errors.password?.message} + type={showPassword ? "text" : "password"} + slotProps={{ + input: { + endAdornment: ( + + setShowPassword((prev) => !prev)} + onMouseDown={(e) => e.preventDefault()} + onMouseUp={(e) => e.preventDefault()} + edge="end" + > + {showPassword ? : } + + + ), + }, + }} /> diff --git a/frontend/src/pages/SignUp/SignUp.test.tsx b/frontend/src/pages/SignUp/SignUp.test.tsx index 72ff56575d..1ef919af58 100644 --- a/frontend/src/pages/SignUp/SignUp.test.tsx +++ b/frontend/src/pages/SignUp/SignUp.test.tsx @@ -17,7 +17,9 @@ jest.mock("../../utils/api", () => ({ post: jest.fn(), }, })); -const mockedPost = userClient.post as jest.MockedFunction; +const mockedPost = userClient.post as jest.MockedFunction< + typeof userClient.post +>; describe("Sign Up Components", () => { beforeEach(() => { @@ -36,27 +38,27 @@ describe("Sign Up Components", () => { it("First name field is rendered", () => { render(); - expect(screen.getByTestId("First Name")).toBeInTheDocument(); + expect(screen.getByLabelText(/First name/)).toBeInTheDocument(); }); it("Last name field is rendered", () => { render(); - expect(screen.getByTestId("Last Name")).toBeInTheDocument(); + expect(screen.getByLabelText(/Last name/)).toBeInTheDocument(); }); it("Username field is rendered", () => { render(); - expect(screen.getByTestId("Username")).toBeInTheDocument(); + expect(screen.getByLabelText(/Username/)).toBeInTheDocument(); }); it("Email field is rendered", () => { render(); - expect(screen.getByTestId("Email")).toBeInTheDocument(); + expect(screen.getByLabelText(/Email/)).toBeInTheDocument(); }); it("Password field is rendered", () => { render(); - expect(screen.getByTestId("Password")).toBeInTheDocument(); + expect(screen.getByLabelText(/Password/)).toBeInTheDocument(); }); it("Sign up button is rendered", () => { @@ -104,11 +106,21 @@ describe("Sign Up Events", () => { render(); - fireEvent.change(screen.getByTestId("First Name"), { target: { value: firstName } }); - fireEvent.change(screen.getByTestId("Last Name"), { target: { value: lastName } }); - fireEvent.change(screen.getByTestId("Username"), { target: { value: username } }); - fireEvent.change(screen.getByTestId("Email"), { target: { value: email } }); - fireEvent.change(screen.getByTestId("Password"), { target: { value: password } }); + fireEvent.change(screen.getByLabelText(/First name/), { + target: { value: firstName }, + }); + fireEvent.change(screen.getByLabelText(/Last name/), { + target: { value: lastName }, + }); + fireEvent.change(screen.getByLabelText(/Username/), { + target: { value: username }, + }); + fireEvent.change(screen.getByLabelText(/Email/), { + target: { value: email }, + }); + fireEvent.change(screen.getByLabelText(/Password/), { + target: { value: password }, + }); fireEvent.click(screen.getByRole("button", { name: "Sign up" })); await waitFor(() => { @@ -127,11 +139,21 @@ describe("Sign Up Events", () => { render(); - fireEvent.change(screen.getByTestId("First Name"), { target: { value: firstName } }); - fireEvent.change(screen.getByTestId("Last Name"), { target: { value: lastName } }); - fireEvent.change(screen.getByTestId("Username"), { target: { value: invalidUsername } }); - fireEvent.change(screen.getByTestId("Email"), { target: { value: email } }); - fireEvent.change(screen.getByTestId("Password"), { target: { value: password } }); + fireEvent.change(screen.getByLabelText(/First name/), { + target: { value: firstName }, + }); + fireEvent.change(screen.getByLabelText(/Last name/), { + target: { value: lastName }, + }); + fireEvent.change(screen.getByLabelText(/Username/), { + target: { value: invalidUsername }, + }); + fireEvent.change(screen.getByLabelText(/Email/), { + target: { value: email }, + }); + fireEvent.change(screen.getByLabelText(/Password/), { + target: { value: password }, + }); fireEvent.click(screen.getByRole("button", { name: "Sign up" })); await waitFor(() => { @@ -144,11 +166,21 @@ describe("Sign Up Events", () => { render(); - fireEvent.change(screen.getByTestId("First Name"), { target: { value: firstName } }); - fireEvent.change(screen.getByTestId("Last Name"), { target: { value: lastName } }); - fireEvent.change(screen.getByTestId("Username"), { target: { value: username } }); - fireEvent.change(screen.getByTestId("Email"), { target: { value: invalidEmail } }); - fireEvent.change(screen.getByTestId("Password"), { target: { value: password } }); + fireEvent.change(screen.getByLabelText(/First name/), { + target: { value: firstName }, + }); + fireEvent.change(screen.getByLabelText(/Last name/), { + target: { value: lastName }, + }); + fireEvent.change(screen.getByLabelText(/Username/), { + target: { value: username }, + }); + fireEvent.change(screen.getByLabelText(/Email/), { + target: { value: invalidEmail }, + }); + fireEvent.change(screen.getByLabelText(/Password/), { + target: { value: password }, + }); fireEvent.click(screen.getByRole("button", { name: "Sign up" })); await waitFor(() => { @@ -161,11 +193,21 @@ describe("Sign Up Events", () => { render(); - fireEvent.change(screen.getByTestId("First Name"), { target: { value: firstName } }); - fireEvent.change(screen.getByTestId("Last Name"), { target: { value: lastName } }); - fireEvent.change(screen.getByTestId("Username"), { target: { value: username } }); - fireEvent.change(screen.getByTestId("Email"), { target: { value: email } }); - fireEvent.change(screen.getByTestId("Password"), { target: { value: invalidPassword } }); + fireEvent.change(screen.getByLabelText(/First name/), { + target: { value: firstName }, + }); + fireEvent.change(screen.getByLabelText(/Last name/), { + target: { value: lastName }, + }); + fireEvent.change(screen.getByLabelText(/Username/), { + target: { value: username }, + }); + fireEvent.change(screen.getByLabelText(/Email/), { + target: { value: email }, + }); + fireEvent.change(screen.getByLabelText(/Password/), { + target: { value: invalidPassword }, + }); fireEvent.click(screen.getByRole("button", { name: "Sign up" })); await waitFor(() => { diff --git a/frontend/src/pages/SignUp/index.tsx b/frontend/src/pages/SignUp/index.tsx index fd7264a6a0..167f91894d 100644 --- a/frontend/src/pages/SignUp/index.tsx +++ b/frontend/src/pages/SignUp/index.tsx @@ -1,73 +1,47 @@ -import { Box, Button, Stack, Typography } from "@mui/material"; +import { + Box, + Button, + IconButton, + InputAdornment, + Stack, + TextField, + Typography, +} from "@mui/material"; import SignUpSvg from "../../assets/signup.svg?react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../../contexts/AuthContext"; -import CustomTextField from "../../components/CustomTextField"; import { emailValidator, nameValidator, passwordValidator, usernameValidator, } from "../../utils/validators"; -import { useRef, useState } from "react"; +import { useState } from "react"; import { ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; +import { useForm } from "react-hook-form"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; const SignUp: React.FC = () => { const navigate = useNavigate(); const auth = useAuth(); + const [showPassword, setShowPassword] = useState(false); if (!auth) { throw new Error("useAuth() must be used within AuthProvider"); } const { signup } = auth; - const formValues = useRef({ - firstName: "", - lastName: "", - username: "", - email: "", - password: "", - }); - const formValidity = useRef({ - firstName: false, - lastName: false, - username: false, - email: false, - password: false, - }); - const [emptyFields, setEmptyFields] = useState<{ [key: string]: boolean }>({ - firstName: false, - lastName: false, - username: false, - email: false, - password: false, - }); - - const handleInputChange = ( - field: keyof typeof formValues.current, - value: string, - isValid: boolean, - ) => { - formValues.current[field] = value; - formValidity.current[field] = isValid; - setEmptyFields((prevState) => ({ ...prevState, [field]: !value })); - }; - - const handleSignUp = (event: React.FormEvent) => { - event.preventDefault(); - - if (!Object.values(formValidity.current).every((isValid) => isValid)) { - // Mark untouched required fields red - Object.entries(formValues.current).forEach(([field, value]) => { - setEmptyFields((prevState) => ({ ...prevState, [field]: !value })); - }); - return; - } - - const { firstName, lastName, username, email, password } = - formValues.current; - signup(firstName, lastName, username, email, password); - }; + const { + register, + handleSubmit, + formState: { errors }, + } = useForm<{ + firstName: string; + lastName: string; + username: string; + email: string; + password: string; + }>({ mode: "all" }); return ( { ({ marginTop: theme.spacing(2), marginBottom: theme.spacing(2), })} - onSubmit={handleSignUp} - noValidate + onSubmit={handleSubmit((data) => + signup( + data.firstName, + data.lastName, + data.username, + data.email, + data.password + ) + )} > - - handleInputChange("firstName", value, isValid) - } + fullWidth + margin="normal" + {...register("firstName", { validate: { nameValidator } })} + error={!!errors.firstName} + helperText={errors.firstName?.message} /> - - handleInputChange("lastName", value, isValid) - } + fullWidth + margin="normal" + {...register("lastName", { validate: { nameValidator } })} + error={!!errors.lastName} + helperText={errors.lastName?.message} /> - - handleInputChange("username", value, isValid) - } + fullWidth + margin="normal" + {...register("username", { validate: { usernameValidator } })} + error={!!errors.username} + helperText={errors.username?.message} /> - - handleInputChange("email", value, isValid) - } + fullWidth + margin="normal" + type="email" + {...register("email", { validate: { emailValidator } })} + error={!!errors.email} + helperText={errors.email?.message} /> - - handleInputChange("password", value, isValid) - } - isPasswordField + fullWidth + margin="normal" + type="password" + {...register("password", { validate: { passwordValidator } })} + error={!!errors.password} + helperText={errors.password?.message} + typeof={showPassword ? "text" : "password"} + slotProps={{ + input: { + endAdornment: ( + + setShowPassword(!showPassword)} + edge="end" + > + {showPassword ? ( + ({ fontSize: theme.spacing(2.5) })} + /> + ) : ( + ({ fontSize: theme.spacing(2.5) })} + /> + )} + + + ), + }, + }} /> diff --git a/frontend/src/utils/validators.ts b/frontend/src/utils/validators.ts index b7bb30a78c..13b8d8f745 100644 --- a/frontend/src/utils/validators.ts +++ b/frontend/src/utils/validators.ts @@ -13,7 +13,7 @@ export const nameValidator = (value: string) => { return "Name must contain only alphabetical, hyphen and white space characters"; } - return ""; + return true; }; export const usernameValidator = (value: string) => { @@ -25,7 +25,7 @@ export const usernameValidator = (value: string) => { return "Username must contain only alphanumeric, underscore and full stop characters"; } - return ""; + return true; }; export const emailValidator = (value: string) => { @@ -33,7 +33,7 @@ export const emailValidator = (value: string) => { return "Email is invalid"; } - return ""; + return true; }; export const passwordValidator = (value: string) => { @@ -57,5 +57,5 @@ export const passwordValidator = (value: string) => { return "Password must contain at least 1 special character"; } - return ""; + return true; }; From 69a74da0d7fd919aeeb416222789ea1f9e108110 Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Wed, 2 Oct 2024 10:32:24 +0800 Subject: [PATCH 44/78] Refactor edit profile modal --- .../src/components/EditProfileModal/index.tsx | 36 ++++--------------- frontend/src/utils/validators.ts | 8 +++++ 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/EditProfileModal/index.tsx b/frontend/src/components/EditProfileModal/index.tsx index 7d5d5a3509..9541917328 100644 --- a/frontend/src/components/EditProfileModal/index.tsx +++ b/frontend/src/components/EditProfileModal/index.tsx @@ -10,6 +10,7 @@ import { } from "@mui/material"; import { useForm } from "react-hook-form"; import { useProfile } from "../../contexts/ProfileContext"; +import { bioValidator, nameValidator } from "../../utils/validators"; interface EditProfileModalProps { onClose: () => void; @@ -25,8 +26,6 @@ const StyledForm = styled("form")(({ theme }) => ({ const EditProfileModal: React.FC = (props) => { const { open, onClose, currFirstName, currLastName, currBiography } = props; - const nameCharLimit = 50; - const bioCharLimit = 255; const { register, @@ -70,20 +69,10 @@ const EditProfileModal: React.FC = (props) => { label="First name" margin="normal" {...register("firstName", { - required: true, - minLength: { value: 1, message: "Required field" }, - maxLength: { - value: nameCharLimit, - message: "Max length exceeded", - }, - pattern: { - value: /^[a-zA-Z\s-]*$/, - message: - "Only alphabetical, hyphen and white space characters allowed", - }, + validate: { nameValidator }, })} error={!!errors.firstName} - helperText={errors.firstName && errors.firstName.message} + helperText={errors.firstName?.message} /> = (props) => { label="Last name" margin="normal" {...register("lastName", { - required: true, - minLength: { value: 1, message: "Required field" }, - maxLength: { - value: nameCharLimit, - message: "Max length exceeded", - }, - pattern: { - value: /^[a-zA-Z\s-]*$/, - message: - "Only alphabetical, hyphen and white space characters allowed", - }, + validate: { nameValidator }, })} error={!!errors.lastName} - helperText={errors.lastName && errors.lastName.message} + helperText={errors.lastName?.message} /> = (props) => { label="Biography" margin="normal" {...register("biography", { - maxLength: { - value: bioCharLimit, - message: "Max length exceeded", - }, + validate: { bioValidator }, })} /> { return true; }; +export const bioValidator = (value: string) => { + if (value.length > 255) { + return "Biography must be at most 255 characters long"; + } + + return true; +}; + export const passwordValidator = (value: string) => { if (value.length < 8) { return "Password must be at least 8 characters long"; From f31d9797694695e6cc3aa524c6ffadf5fb817dc9 Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Wed, 2 Oct 2024 10:34:01 +0800 Subject: [PATCH 45/78] Refactor change password modal --- .../components/ChangePasswordModal/index.tsx | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/ChangePasswordModal/index.tsx b/frontend/src/components/ChangePasswordModal/index.tsx index 753e6ba20a..44c1cc9f2c 100644 --- a/frontend/src/components/ChangePasswordModal/index.tsx +++ b/frontend/src/components/ChangePasswordModal/index.tsx @@ -14,6 +14,7 @@ import { import { useState } from "react"; import { useForm } from "react-hook-form"; import { useProfile } from "../../contexts/ProfileContext"; +import { passwordValidator } from "../../utils/validators"; interface ChangePasswordModalProps { open: boolean; @@ -111,25 +112,7 @@ const ChangePasswordModal: React.FC = (props) => { }, }} {...register("newPassword", { - minLength: { - value: 8, - message: "Password must be at least 8 characters long", - }, - validate: { - atLeastOneLowercase: (value) => - /[a-z]/.test(value) || - "Password must contain at least 1 lowercase letter", - atLeastOneUppercase: (value) => - /[A-Z]/.test(value) || - "Password must contain at least 1 uppercase letter", - atLeastOneDigit: (value) => - /\d/.test(value) || - "Password must contain at least 1 digit", - atLeastOneSpecialCharacter: (value) => - // eslint-disable-next-line no-useless-escape - /[ `!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/.test(value) || - "Password must contain at least 1 special character", - }, + validate: { passwordValidator }, })} error={!!errors.newPassword} helperText={errors.newPassword?.message} From 276d25aa764c1d6b6a4701e84be72264d79c1b18 Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Wed, 2 Oct 2024 16:40:58 +0800 Subject: [PATCH 46/78] Update password field --- .../components/PasswordTextField/index.tsx | 284 +++++++++--------- frontend/src/pages/LogIn/index.tsx | 22 +- frontend/src/pages/SignUp/index.tsx | 38 +-- 3 files changed, 151 insertions(+), 193 deletions(-) diff --git a/frontend/src/components/PasswordTextField/index.tsx b/frontend/src/components/PasswordTextField/index.tsx index 604d0ddb46..281cc4e59b 100644 --- a/frontend/src/components/PasswordTextField/index.tsx +++ b/frontend/src/components/PasswordTextField/index.tsx @@ -1,179 +1,175 @@ import { Visibility, VisibilityOff } from "@mui/icons-material"; import { - FormControl, - FormHelperText, IconButton, InputAdornment, TextField, + TextFieldProps, } from "@mui/material"; -import { useEffect, useState } from "react"; +import { forwardRef, useState } from "react"; -interface PasswordTextFieldProps { - label: string; - passwordVal: boolean; - password: string; - setPassword: (password: string) => void; - isMatch: boolean; - passwordToMatch?: string; - setValidity: (isValid: boolean) => void; -} +// interface PasswordTextFieldProps { +// label: string; +// passwordVal: boolean; +// password: string; +// setPassword: (password: string) => void; +// isMatch: boolean; +// passwordToMatch?: string; +// setValidity: (isValid: boolean) => void; +// } -const PasswordTextField: React.FC = ({ - label, - passwordVal, - password, - setPassword, - isMatch, - passwordToMatch, - setValidity, -}) => { - const validatePasswordError = ( - passwordVal: boolean, - password: string - ): boolean => { - return passwordVal - ? password.length < 8 || - !/[a-z]/.test(password) || - !/[A-Z]/.test(password) || - !/\d/.test(password) || - // eslint-disable-next-line no-useless-escape - !/[ `!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/.test(password) - : false; - }; +const PasswordTextField = forwardRef( + (props, ref) => { + const [showPassword, setShowPassword] = useState(false); + // const validatePasswordError = ( + // passwordVal: boolean, + // password: string + // ): boolean => { + // return passwordVal + // ? password.length < 8 || + // !/[a-z]/.test(password) || + // !/[A-Z]/.test(password) || + // !/\d/.test(password) || + // // eslint-disable-next-line no-useless-escape + // !/[ `!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/.test(password) + // : false; + // }; - const comparePasswordError = ( - isMatch: boolean, - password: string, - passwordToMatch: string | undefined - ): boolean => { - return isMatch ? password != passwordToMatch : false; - }; + // const comparePasswordError = ( + // isMatch: boolean, + // password: string, + // passwordToMatch: string | undefined + // ): boolean => { + // return isMatch ? password != passwordToMatch : false; + // }; - const checkEmptyError = (password: string): boolean => { - return !password; - }; + // const checkEmptyError = (password: string): boolean => { + // return !password; + // }; - const isInvalid = - validatePasswordError(passwordVal, password) || - comparePasswordError(isMatch, password, passwordToMatch) || - checkEmptyError(password); + // const isInvalid = + // validatePasswordError(passwordVal, password) || + // comparePasswordError(isMatch, password, passwordToMatch) || + // checkEmptyError(password); - //to listen to other password input changes - useEffect(() => { - setValidity( - !( - validatePasswordError(passwordVal, password) || - comparePasswordError(isMatch, password, passwordToMatch) || - checkEmptyError(password) - ) - ); - }, [passwordVal, isMatch, password, passwordToMatch, setValidity]); + // //to listen to other password input changes + // useEffect(() => { + // setValidity( + // !( + // validatePasswordError(passwordVal, password) || + // comparePasswordError(isMatch, password, passwordToMatch) || + // checkEmptyError(password) + // ) + // ); + // }, [passwordVal, isMatch, password, passwordToMatch, setValidity]); - const [showPassword, setShowPassword] = useState(false); + // const [showPassword, setShowPassword] = useState(false); - const handleClickShowPassword = () => setShowPassword((show) => !show); + // const handleClickShowPassword = () => setShowPassword((show) => !show); - const handleMouseDownPassword = ( - event: React.MouseEvent - ) => { - event.preventDefault(); - }; + // const handleMouseDownPassword = ( + // event: React.MouseEvent + // ) => { + // event.preventDefault(); + // }; - const handleMouseUpPassword = ( - event: React.MouseEvent - ) => { - event.preventDefault(); - }; + // const handleMouseUpPassword = ( + // event: React.MouseEvent + // ) => { + // event.preventDefault(); + // }; - const handlePasswordChange = (event: React.ChangeEvent) => { - const val = event.target.value; - setPassword(val); - setValidity( - !( - validatePasswordError(passwordVal, val) || - comparePasswordError(isMatch, val, passwordToMatch) || - checkEmptyError(val) - ) - ); - }; + // const handlePasswordChange = (event: React.ChangeEvent) => { + // const val = event.target.value; + // setPassword(val); + // setValidity( + // !( + // validatePasswordError(passwordVal, val) || + // comparePasswordError(isMatch, val, passwordToMatch) || + // checkEmptyError(val) + // ) + // ); + // }; - return ( - + return ( setShowPassword((prev) => !prev)} + onMouseDown={(e) => e.preventDefault()} + onMouseUp={(e) => e.preventDefault()} edge="end" > - {showPassword ? : } + {showPassword ? ( + ({ fontSize: theme.spacing(2.5) })} + /> + ) : ( + ({ fontSize: theme.spacing(2.5) })} + /> + )} ), }, }} /> - {checkEmptyError(password) && ( - - Required field - - )} - {validatePasswordError(passwordVal, password) && ( -
- ({ color: theme.palette.success.main })} - error={password.length < 8} - > - Password must be at least 8 characters long - - ({ color: theme.palette.success.main })} - error={!/[a-z]/.test(password)} - > - Password must contain at least 1 lowercase letter - - ({ color: theme.palette.success.main })} - error={!/[A-Z]/.test(password)} - > - Password must contain at least 1 uppercase letter - - ({ color: theme.palette.success.main })} - error={!/\d/.test(password)} - > - Password must contain at least 1 digit - - ({ color: theme.palette.success.main })} - // eslint-disable-next-line no-useless-escape - error={!/[ `!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/.test(password)} - > - Password must contain at least 1 special character - -
- )} - {comparePasswordError(isMatch, password, passwordToMatch) && ( - - Password does not match - - )} -
- ); -}; + // {checkEmptyError(password) && ( + // + // Required field + // + // )} + // {validatePasswordError(passwordVal, password) && ( + //
+ // ({ color: theme.palette.success.main })} + // error={password.length < 8} + // > + // Password must be at least 8 characters long + // + // ({ color: theme.palette.success.main })} + // error={!/[a-z]/.test(password)} + // > + // Password must contain at least 1 lowercase letter + // + // ({ color: theme.palette.success.main })} + // error={!/[A-Z]/.test(password)} + // > + // Password must contain at least 1 uppercase letter + // + // ({ color: theme.palette.success.main })} + // error={!/\d/.test(password)} + // > + // Password must contain at least 1 digit + // + // ({ color: theme.palette.success.main })} + // // eslint-disable-next-line no-useless-escape + // error={!/[ `!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/.test(password)} + // > + // Password must contain at least 1 special character + // + //
+ // )} + // {comparePasswordError(isMatch, password, passwordToMatch) && ( + // + // Password does not match + // + // )} + // + ); + } +); export default PasswordTextField; diff --git a/frontend/src/pages/LogIn/index.tsx b/frontend/src/pages/LogIn/index.tsx index b6efe6c53d..c569dc2a45 100644 --- a/frontend/src/pages/LogIn/index.tsx +++ b/frontend/src/pages/LogIn/index.tsx @@ -16,6 +16,7 @@ import "react-toastify/dist/ReactToastify.css"; import { useForm } from "react-hook-form"; import { useState } from "react"; import { Visibility, VisibilityOff } from "@mui/icons-material"; +import PasswordTextField from "../../components/PasswordTextField"; const LogIn: React.FC = () => { const navigate = useNavigate(); @@ -78,31 +79,14 @@ const LogIn: React.FC = () => { error={!!errors.email} helperText={errors.email?.message} /> - - setShowPassword((prev) => !prev)} - onMouseDown={(e) => e.preventDefault()} - onMouseUp={(e) => e.preventDefault()} - edge="end" - > - {showPassword ? : } - - - ), - }, - }} /> diff --git a/frontend/src/components/EditProfileModal/index.tsx b/frontend/src/components/EditProfileModal/index.tsx index 03d2103eff..5f5730a9cd 100644 --- a/frontend/src/components/EditProfileModal/index.tsx +++ b/frontend/src/components/EditProfileModal/index.tsx @@ -11,6 +11,7 @@ import { import { useForm } from "react-hook-form"; import { useProfile } from "../../contexts/ProfileContext"; import { bioValidator, nameValidator } from "../../utils/validators"; +import { USE_PROFILE_ERROR_MESSAGE } from "../../utils/constants"; interface EditProfileModalProps { onClose: () => void; @@ -31,6 +32,7 @@ const EditProfileModal: React.FC = (props) => { register, formState: { errors, isValid, isDirty }, handleSubmit, + reset, } = useForm<{ firstName: string; lastName: string; @@ -47,20 +49,29 @@ const EditProfileModal: React.FC = (props) => { const profile = useProfile(); if (!profile) { - throw new Error("useProfile() must be used within ProfileContextProvider"); + throw new Error(USE_PROFILE_ERROR_MESSAGE); } const { updateProfile } = profile; return ( - - Edit profile + { + onClose(); + reset(); + }} + > + + Edit profile + - + { updateProfile(data); onClose(); + reset(); })} > = (props) => { required label="First name" margin="normal" + sx={(theme) => ({ marginTop: theme.spacing(1) })} {...register("firstName", { - setValueAs: (value: string) => value.trim(), + setValueAs: (value: string) => value.trim(), validate: { nameValidator }, })} error={!!errors.firstName} @@ -80,8 +92,9 @@ const EditProfileModal: React.FC = (props) => { required label="Last name" margin="normal" + sx={(theme) => ({ marginTop: theme.spacing(1) })} {...register("lastName", { - setValueAs: (value: string) => value.trim(), + setValueAs: (value: string) => value.trim(), validate: { nameValidator }, })} error={!!errors.lastName} @@ -92,8 +105,9 @@ const EditProfileModal: React.FC = (props) => { multiline label="Biography" margin="normal" + sx={(theme) => ({ marginTop: theme.spacing(1) })} {...register("biography", { - setValueAs: (value: string) => value.trim(), + setValueAs: (value: string) => value.trim(), validate: { bioValidator }, })} /> @@ -106,7 +120,10 @@ const EditProfileModal: React.FC = (props) => { fullWidth variant="contained" color="secondary" - onClick={onClose} + onClick={() => { + onClose(); + reset(); + }} > Cancel diff --git a/frontend/src/pages/Profile/index.tsx b/frontend/src/pages/Profile/index.tsx index 1f3d2c697a..1d5bf92076 100644 --- a/frontend/src/pages/Profile/index.tsx +++ b/frontend/src/pages/Profile/index.tsx @@ -9,7 +9,10 @@ import ServerError from "../../components/ServerError"; import EditProfileModal from "../../components/EditProfileModal"; import ChangePasswordModal from "../../components/ChangePasswordModal"; import { useProfile } from "../../contexts/ProfileContext"; -import { USE_AUTH_ERROR_MESSAGE } from "../../utils/constants"; +import { + USE_AUTH_ERROR_MESSAGE, + USE_PROFILE_ERROR_MESSAGE, +} from "../../utils/constants"; const ProfilePage: React.FC = () => { const { userId } = useParams<{ userId: string }>(); @@ -22,7 +25,7 @@ const ProfilePage: React.FC = () => { } if (!profile) { - throw new Error("useProfile() must be used within ProfileContextProvider"); + throw new Error(USE_PROFILE_ERROR_MESSAGE); } const { diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index be9a45ee7f..7f7b65bb13 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -2,9 +2,11 @@ export const complexityList: string[] = ["Easy", "Medium", "Hard"]; export const languageList = ["Python", "Java"]; -/* AuthProvider Error */ +/* Context Provider Errors */ export const USE_AUTH_ERROR_MESSAGE = "useAuth() must be used within AuthProvider"; +export const USE_PROFILE_ERROR_MESSAGE = + "useProfile() must be used within ProfileContextProvider"; /* Name Validation */ export const NAME_REQUIRED_ERROR_MESSAGE = "Name is required"; @@ -39,7 +41,8 @@ export const PASSWORD_DIGIT_ERROR_MESSAGE = "Password must contain at least 1 digit"; export const PASSWORD_SPECIAL_CHAR_ERROR_MESSAGE = "Password must contain at least 1 special character"; -export const PASSWORD_WEAK_MESSAGE = "Password is weak"; +export const PASSWORD_WEAK_ERROR_MESSAGE = "Password is weak"; +export const PASSWORD_MISMATCH_ERROR_MESSAGE = "Password does not match"; /* Toast Messages */ // Authentication diff --git a/frontend/src/utils/validators.ts b/frontend/src/utils/validators.ts index 014c449e6d..c4d403ef49 100644 --- a/frontend/src/utils/validators.ts +++ b/frontend/src/utils/validators.ts @@ -12,7 +12,7 @@ import { PASSWORD_MIN_LENGTH_ERROR_MESSAGE, PASSWORD_SPECIAL_CHAR_ERROR_MESSAGE, PASSWORD_UPPER_CASE_ERROR_MESSAGE, - PASSWORD_WEAK_MESSAGE, + PASSWORD_WEAK_ERROR_MESSAGE, USERNAME_ALLOWED_CHAR_ERROR_MESSAGE, USERNAME_LENGTH_ERROR_MESSAGE, } from "./constants"; @@ -87,13 +87,14 @@ const specialCharValidator = (value: string) => { export const passwordValidator = (value: string) => { if ( - !minLengthValidator(value) || - !lowerCaseValidator(value) || - !upperCaseValidator(value) || - !digitValidator(value) || - !specialCharValidator(value) + value && + (!minLengthValidator(value) || + !lowerCaseValidator(value) || + !upperCaseValidator(value) || + !digitValidator(value) || + !specialCharValidator(value)) ) { - return PASSWORD_WEAK_MESSAGE; + return PASSWORD_WEAK_ERROR_MESSAGE; } return true; From 05c177d1e96ccec1a738e16920b3416b5b88ce0a Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Fri, 4 Oct 2024 00:33:47 +0800 Subject: [PATCH 56/78] Add required error message for sign up password field --- .../components/PasswordTextField/index.tsx | 19 +++++++++++++++++-- frontend/src/pages/SignUp/index.tsx | 6 +++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/PasswordTextField/index.tsx b/frontend/src/components/PasswordTextField/index.tsx index 41f97601fe..a463914f67 100644 --- a/frontend/src/components/PasswordTextField/index.tsx +++ b/frontend/src/components/PasswordTextField/index.tsx @@ -1,4 +1,10 @@ -import { Check, Clear, Visibility, VisibilityOff } from "@mui/icons-material"; +import { + Check, + Circle, + Clear, + Visibility, + VisibilityOff, +} from "@mui/icons-material"; import { IconButton, InputAdornment, @@ -37,7 +43,16 @@ const TooltipMessage: React.FC<{ paddingTop: theme.spacing(0.7), })} > - {validator.validate(input) ? ( + {!input ? ( + ({ + fontSize: theme.spacing(0.8), + marginTop: theme.spacing(0.8), + marginLeft: theme.spacing(0.8), + color: "white", + })} + /> + ) : validator.validate(input) ? ( ({ fontSize: theme.spacing(2.5), diff --git a/frontend/src/pages/SignUp/index.tsx b/frontend/src/pages/SignUp/index.tsx index 710e076fad..0736e57f3f 100644 --- a/frontend/src/pages/SignUp/index.tsx +++ b/frontend/src/pages/SignUp/index.tsx @@ -12,7 +12,10 @@ import { ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; import { useForm } from "react-hook-form"; import PasswordTextField from "../../components/PasswordTextField"; -import { USE_AUTH_ERROR_MESSAGE } from "../../utils/constants"; +import { + PASSWORD_REQUIRED_ERROR_MESSAGE, + USE_AUTH_ERROR_MESSAGE, +} from "../../utils/constants"; const SignUp: React.FC = () => { const navigate = useNavigate(); @@ -138,6 +141,7 @@ const SignUp: React.FC = () => { input={watch("password", "")} {...register("password", { setValueAs: (value: string) => value.trim(), + required: PASSWORD_REQUIRED_ERROR_MESSAGE, validate: { passwordValidator }, })} error={!!errors.password} From 24b06c1f632b7040028deccb1a575c369a036b32 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Fri, 4 Oct 2024 14:22:48 +0800 Subject: [PATCH 57/78] Keep tooltip open if password field is in focus --- .../components/ChangePasswordModal/index.tsx | 9 ++--- .../components/PasswordTextField/index.tsx | 33 +++++++++++++++---- frontend/src/contexts/AuthContext.tsx | 29 +++++++++------- frontend/src/contexts/ProfileContext.tsx | 4 +-- frontend/src/reducers/questionReducer.ts | 10 +++--- 5 files changed, 54 insertions(+), 31 deletions(-) diff --git a/frontend/src/components/ChangePasswordModal/index.tsx b/frontend/src/components/ChangePasswordModal/index.tsx index 506b1ffc2f..cabb9babcd 100644 --- a/frontend/src/components/ChangePasswordModal/index.tsx +++ b/frontend/src/components/ChangePasswordModal/index.tsx @@ -108,12 +108,9 @@ const ChangePasswordModal: React.FC = (props) => { {...register("confirmPassword", { setValueAs: (value: string) => value.trim(), validate: { - matchPassword: (value) => { - return ( - watch("newPassword") === value || - PASSWORD_MISMATCH_ERROR_MESSAGE - ); - }, + matchPassword: (value) => + watch("newPassword") === value || + PASSWORD_MISMATCH_ERROR_MESSAGE, }, })} error={!!errors.confirmPassword} diff --git a/frontend/src/components/PasswordTextField/index.tsx b/frontend/src/components/PasswordTextField/index.tsx index a463914f67..7358693e25 100644 --- a/frontend/src/components/PasswordTextField/index.tsx +++ b/frontend/src/components/PasswordTextField/index.tsx @@ -29,10 +29,7 @@ const TooltipMessage: React.FC<{ ({ - paddingLeft: theme.spacing(0.2), - paddingRight: theme.spacing(0.2), - paddingTop: 0, - paddingBottom: 0, + padding: theme.spacing(0, 0.2), alignItems: "flex-start", })} > @@ -63,7 +60,7 @@ const TooltipMessage: React.FC<{ ({ fontSize: theme.spacing(2.5), - color: "#9A2A2A", + color: "error.main", })} /> )} @@ -79,11 +76,31 @@ const PasswordTextField = forwardRef< HTMLInputElement, TextFieldProps & { displayTooltip?: boolean; input?: string } >((props, ref) => { - const [showPassword, setShowPassword] = useState(false); const { displayTooltip = false, input = "", ...rest } = props; + const [showPassword, setShowPassword] = useState(false); + const [openTooltip, setOpenTooltip] = useState(false); + const [isFocused, setIsFocused] = useState(false); + + const handleMouseEnter = () => { + setOpenTooltip(true); + }; + const handleMouseLeave = () => { + if (!isFocused) { + setOpenTooltip(false); + } + }; + const handleFocus = () => { + setIsFocused(true); + setOpenTooltip(true); + }; + const handleBlur = () => { + setIsFocused(false); + setOpenTooltip(false); + }; return ( } arrow placement="right" @@ -116,6 +133,10 @@ const PasswordTextField = forwardRef< ), }, }} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + onFocus={handleFocus} + onBlur={handleBlur} /> ); diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 07d2c306ab..f0d60a3899 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -37,20 +37,25 @@ const AuthContext = createContext(null); const AuthProvider: React.FC<{ children?: React.ReactNode }> = (props) => { const { children } = props; const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const navigate = useNavigate(); useEffect(() => { const accessToken = localStorage.getItem("token"); - userClient - .get("/auth/verify-token", { - headers: { Authorization: `Bearer ${accessToken}` }, - }) - .then((res) => setUser(res.data.data)) - .catch(() => setUser(null)) - .finally(() => { - setTimeout(() => setLoading(false), 500); - }); + if (accessToken) { + setLoading(true); + userClient + .get("/auth/verify-token", { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + .then((res) => setUser(res.data.data)) + .catch(() => setUser(null)) + .finally(() => { + setTimeout(() => setLoading(false), 500); + }); + } else { + setUser(null); + } }, []); const signup = ( @@ -71,7 +76,7 @@ const AuthProvider: React.FC<{ children?: React.ReactNode }> = (props) => { .then(() => login(email, password)) .catch((err) => { setUser(null); - toast.error(err.response.data.message); + toast.error(err.response?.data.message || err.message); }); }; @@ -89,7 +94,7 @@ const AuthProvider: React.FC<{ children?: React.ReactNode }> = (props) => { }) .catch((err) => { setUser(null); - toast.error(err.response.data.message); + toast.error(err.response?.data.message || err.message); }); }; diff --git a/frontend/src/contexts/ProfileContext.tsx b/frontend/src/contexts/ProfileContext.tsx index 21f9423d19..0866b503da 100644 --- a/frontend/src/contexts/ProfileContext.tsx +++ b/frontend/src/contexts/ProfileContext.tsx @@ -68,7 +68,7 @@ const ProfileContextProvider: React.FC<{ children: React.ReactNode }> = ({ }) .catch((err) => { const message = - err.response.data.message || FAILED_PROFILE_UPDATE_MESSAGE; + err.response?.data.message || FAILED_PROFILE_UPDATE_MESSAGE; toast.error(message); }); }; @@ -89,7 +89,7 @@ const ProfileContextProvider: React.FC<{ children: React.ReactNode }> = ({ ) .then(() => toast.success(SUCCESS_PW_UPDATE_MESSAGE)) .catch((err) => { - const message = err.response.data.message || FAILED_PW_UPDATE_MESSAGE; + const message = err.response?.data.message || FAILED_PW_UPDATE_MESSAGE; toast.error(message); }); }; diff --git a/frontend/src/reducers/questionReducer.ts b/frontend/src/reducers/questionReducer.ts index b16c6232b4..2d462a5642 100644 --- a/frontend/src/reducers/questionReducer.ts +++ b/frontend/src/reducers/questionReducer.ts @@ -112,7 +112,7 @@ export const createQuestion = async ( .catch((err) => { dispatch({ type: QuestionActionTypes.ERROR_CREATING_QUESTION, - payload: err.response.data.message, + payload: err.response?.data.message || err.message, }); return false; }); @@ -130,7 +130,7 @@ export const getQuestionCategories = (dispatch: Dispatch) => { .catch((err) => dispatch({ type: QuestionActionTypes.ERROR_FETCHING_QUESTION_CATEGORIES, - payload: err.response.data.message, + payload: err.response?.data.message || err.message, }) ); }; @@ -162,7 +162,7 @@ export const getQuestionList = ( .catch((err) => dispatch({ type: QuestionActionTypes.ERROR_FETCHING_QUESTION_LIST, - payload: err.response.data.message, + payload: err.response?.data.message || err.message, }) ); }; @@ -182,7 +182,7 @@ export const getQuestionById = ( .catch((err) => dispatch({ type: QuestionActionTypes.ERROR_FETCHING_SELECTED_QN, - payload: err.response.data.message, + payload: err.response?.data.message || err.message, }) ); }; @@ -218,7 +218,7 @@ export const updateQuestionById = async ( .catch((err) => { dispatch({ type: QuestionActionTypes.ERROR_UPDATING_QUESTION, - payload: err.response.data.message, + payload: err.response?.data.message || err.message, }); return false; }); From 519295cc20378843f6befb3eea049e9db382bdf8 Mon Sep 17 00:00:00 2001 From: jolynloh Date: Sat, 5 Oct 2024 00:05:28 +0800 Subject: [PATCH 58/78] Containerise frontend and update docker-compose --- .../src/controllers/questionController.ts | 20 +++---------------- docker-compose.yml | 11 ++++++++++ frontend/.dockerignore | 1 + frontend/Dockerfile | 13 ++++++++++++ 4 files changed, 28 insertions(+), 17 deletions(-) create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile diff --git a/backend/question-service/src/controllers/questionController.ts b/backend/question-service/src/controllers/questionController.ts index 879e653693..4d92862989 100644 --- a/backend/question-service/src/controllers/questionController.ts +++ b/backend/question-service/src/controllers/questionController.ts @@ -1,6 +1,6 @@ import { Request, Response } from "express"; import Question, { IQuestion } from "../models/Question.ts"; -import { checkIsExistingQuestion, sortAlphabetically } from "../utils/utils.ts"; +import { checkIsExistingQuestion } from "../utils/utils.ts"; import { DUPLICATE_QUESTION_MESSAGE, QN_DESC_EXCEED_CHAR_LIMIT_MESSAGE, @@ -13,8 +13,6 @@ import { PAGE_LIMIT_REQUIRED_MESSAGE, PAGE_LIMIT_INCORRECT_FORMAT_MESSAGE, CATEGORIES_RETRIEVED_MESSAGE, - MONGO_OBJ_ID_FORMAT, - MONGO_OBJ_ID_MALFORMED_MESSAGE, } from "../utils/constants.ts"; import { upload } from "../../config/multer"; @@ -80,7 +78,6 @@ export const createImageLink = async ( const uploadPromises = files.map((file) => uploadFileToFirebase(file)); const imageUrls = await Promise.all(uploadPromises); - console.log(imageUrls); res .status(200) .json({ message: "Images uploaded successfully", imageUrls }); @@ -98,13 +95,7 @@ export const updateQuestion = async ( const { id } = req.params; const { title, description } = req.body; - if (!id.match(MONGO_OBJ_ID_FORMAT)) { - res.status(400).json({ message: MONGO_OBJ_ID_MALFORMED_MESSAGE }); - return; - } - const currentQuestion = await Question.findById(id); - if (!currentQuestion) { res.status(404).json({ message: QN_NOT_FOUND_MESSAGE }); return; @@ -212,12 +203,7 @@ export const readQuestionsList = async ( res.status(200).json({ message: QN_RETRIEVED_MESSAGE, questionCount: filteredQuestionCount, - questions: filteredQuestions - .map(formatQuestionResponse) - .map((question) => ({ - ...question, - categories: sortAlphabetically(question.categories), - })), + questions: filteredQuestions.map(formatQuestionResponse), }); } catch (error) { res.status(500).json({ message: SERVER_ERROR_MESSAGE, error }); @@ -255,7 +241,7 @@ export const readCategories = async ( res.status(200).json({ message: CATEGORIES_RETRIEVED_MESSAGE, - categories: sortAlphabetically(uniqueCats), + categories: uniqueCats, }); } catch (error) { res.status(500).json({ message: SERVER_ERROR_MESSAGE, error }); diff --git a/docker-compose.yml b/docker-compose.yml index 9cf2414f67..ddbe2a7940 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,17 @@ services: networks: - peerprep-network restart: on-failure + frontend: + image: peerprep/frontend + build: ./frontend + ports: + - 5173:5173 + depends_on: + - user-service + - question-service + networks: + - peerprep-network + restart: on-failure networks: peerprep-network: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000000..3c3629e647 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1 @@ +node_modules diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000000..7888b16163 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /frontend + +COPY package*.json ./ + +RUN npm ci + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev"] \ No newline at end of file From 8f6c5d93203c2d8f487b39811801ce844173df8b Mon Sep 17 00:00:00 2001 From: jolynloh Date: Sat, 5 Oct 2024 00:08:53 +0800 Subject: [PATCH 59/78] Restore backend question controller --- .../src/controllers/questionController.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/backend/question-service/src/controllers/questionController.ts b/backend/question-service/src/controllers/questionController.ts index 4d92862989..879e653693 100644 --- a/backend/question-service/src/controllers/questionController.ts +++ b/backend/question-service/src/controllers/questionController.ts @@ -1,6 +1,6 @@ import { Request, Response } from "express"; import Question, { IQuestion } from "../models/Question.ts"; -import { checkIsExistingQuestion } from "../utils/utils.ts"; +import { checkIsExistingQuestion, sortAlphabetically } from "../utils/utils.ts"; import { DUPLICATE_QUESTION_MESSAGE, QN_DESC_EXCEED_CHAR_LIMIT_MESSAGE, @@ -13,6 +13,8 @@ import { PAGE_LIMIT_REQUIRED_MESSAGE, PAGE_LIMIT_INCORRECT_FORMAT_MESSAGE, CATEGORIES_RETRIEVED_MESSAGE, + MONGO_OBJ_ID_FORMAT, + MONGO_OBJ_ID_MALFORMED_MESSAGE, } from "../utils/constants.ts"; import { upload } from "../../config/multer"; @@ -78,6 +80,7 @@ export const createImageLink = async ( const uploadPromises = files.map((file) => uploadFileToFirebase(file)); const imageUrls = await Promise.all(uploadPromises); + console.log(imageUrls); res .status(200) .json({ message: "Images uploaded successfully", imageUrls }); @@ -95,7 +98,13 @@ export const updateQuestion = async ( const { id } = req.params; const { title, description } = req.body; + if (!id.match(MONGO_OBJ_ID_FORMAT)) { + res.status(400).json({ message: MONGO_OBJ_ID_MALFORMED_MESSAGE }); + return; + } + const currentQuestion = await Question.findById(id); + if (!currentQuestion) { res.status(404).json({ message: QN_NOT_FOUND_MESSAGE }); return; @@ -203,7 +212,12 @@ export const readQuestionsList = async ( res.status(200).json({ message: QN_RETRIEVED_MESSAGE, questionCount: filteredQuestionCount, - questions: filteredQuestions.map(formatQuestionResponse), + questions: filteredQuestions + .map(formatQuestionResponse) + .map((question) => ({ + ...question, + categories: sortAlphabetically(question.categories), + })), }); } catch (error) { res.status(500).json({ message: SERVER_ERROR_MESSAGE, error }); @@ -241,7 +255,7 @@ export const readCategories = async ( res.status(200).json({ message: CATEGORIES_RETRIEVED_MESSAGE, - categories: uniqueCats, + categories: sortAlphabetically(uniqueCats), }); } catch (error) { res.status(500).json({ message: SERVER_ERROR_MESSAGE, error }); From ef3c043c8b5f191e0172511e47db450b03f047b2 Mon Sep 17 00:00:00 2001 From: jolynloh Date: Sat, 5 Oct 2024 00:20:44 +0800 Subject: [PATCH 60/78] Uncomment find match form --- frontend/src/pages/Home/index.tsx | 61 +++++++++++++++---------------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/frontend/src/pages/Home/index.tsx b/frontend/src/pages/Home/index.tsx index 795f3e4925..353e0fc22d 100644 --- a/frontend/src/pages/Home/index.tsx +++ b/frontend/src/pages/Home/index.tsx @@ -1,40 +1,40 @@ import { - // Autocomplete, - // Button, - // Card, - // FormControl, - // Grid2, - // TextField, + Autocomplete, + Button, + Card, + FormControl, + Grid2, + TextField, Typography, } from "@mui/material"; -// import { useEffect, useReducer, useState } from "react"; +import { useEffect, useReducer, useState } from "react"; import classes from "./index.module.css"; import AppMargin from "../../components/AppMargin"; -// import { -// complexityList, -// languageList, -// maxMatchTimeout, -// minMatchTimeout, -// } from "../../utils/constants"; -// import reducer, { -// getQuestionCategories, -// initialState, -// } from "../../reducers/questionReducer"; -// import CustomChip from "../../components/CustomChip"; -// import homepageImage from "/homepage_image.svg"; +import { + complexityList, + languageList, + maxMatchTimeout, + minMatchTimeout, +} from "../../utils/constants"; +import reducer, { + getQuestionCategories, + initialState, +} from "../../reducers/questionReducer"; +import CustomChip from "../../components/CustomChip"; +import homepageImage from "/homepage_image.svg"; const Home: React.FC = () => { - // const [complexity, setComplexity] = useState([]); - // const [selectedCategories, setSelectedCategories] = useState([]); - // const [language, setLanguage] = useState([]); - // const [timeout, setTimeout] = useState(30); + const [complexity, setComplexity] = useState([]); + const [selectedCategories, setSelectedCategories] = useState([]); + const [language, setLanguage] = useState([]); + const [timeout, setTimeout] = useState(30); - // const [state, dispatch] = useReducer(reducer, initialState); + const [state, dispatch] = useReducer(reducer, initialState); - // useEffect(() => { - // getQuestionCategories(dispatch); - // }, []); + useEffect(() => { + getQuestionCategories(dispatch); + }, []); return ( { Your ultimate technical interview preparation platform to practice whiteboard style interview questions with a peer. - {/* - { objectFit: "contain", }} /> */} - {/* { > Find my match! - */} + ); }; From 5dff1cccd284b8b64948036d70ac62988b8722ae Mon Sep 17 00:00:00 2001 From: jolynloh Date: Sat, 5 Oct 2024 13:45:13 +0800 Subject: [PATCH 61/78] Modify landing logic and routes --- .../src/controllers/questionController.ts | 1 - frontend/package-lock.json | 308 ++++++++++++++++++ frontend/package.json | 1 + frontend/src/App.tsx | 11 +- .../src/components/ProtectedRoutes/index.tsx | 7 +- frontend/src/contexts/AuthContext.tsx | 13 +- frontend/src/pages/Home/index.tsx | 2 +- frontend/src/pages/Landing/index.module.css | 15 + frontend/src/pages/Landing/index.tsx | 40 +++ 9 files changed, 386 insertions(+), 12 deletions(-) create mode 100644 frontend/src/pages/Landing/index.module.css create mode 100644 frontend/src/pages/Landing/index.tsx diff --git a/backend/question-service/src/controllers/questionController.ts b/backend/question-service/src/controllers/questionController.ts index 879e653693..8e08403cc4 100644 --- a/backend/question-service/src/controllers/questionController.ts +++ b/backend/question-service/src/controllers/questionController.ts @@ -80,7 +80,6 @@ export const createImageLink = async ( const uploadPromises = files.map((file) => uploadFileToFirebase(file)); const imageUrls = await Promise.all(uploadPromises); - console.log(imageUrls); res .status(200) .json({ message: "Images uploaded successfully", imageUrls }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1f366ed9cd..e2274b3bcc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.0", + "react-material-ui-carousel": "^3.4.2", "react-router-dom": "^6.26.2", "react-toastify": "^10.0.5", "vite-plugin-svgr": "^4.2.0" @@ -6801,6 +6802,52 @@ "node": ">= 6" } }, + "node_modules/framer-motion": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-4.1.17.tgz", + "integrity": "sha512-thx1wvKzblzbs0XaK2X0G1JuwIdARcoNOW7VVwjO8BUltzXPyONGAElLu6CiCScsOQRI7FIk/45YTFtJw5Yozw==", + "license": "MIT", + "dependencies": { + "framesync": "5.3.0", + "hey-listen": "^1.0.8", + "popmotion": "9.3.6", + "style-value-types": "4.1.4", + "tslib": "^2.1.0" + }, + "optionalDependencies": { + "@emotion/is-prop-valid": "^0.8.2" + }, + "peerDependencies": { + "react": ">=16.8 || ^17.0.0", + "react-dom": ">=16.8 || ^17.0.0" + } + }, + "node_modules/framer-motion/node_modules/@emotion/is-prop-valid": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", + "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emotion/memoize": "0.7.4" + } + }, + "node_modules/framer-motion/node_modules/@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "license": "MIT", + "optional": true + }, + "node_modules/framesync": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/framesync/-/framesync-5.3.0.tgz", + "integrity": "sha512-oc5m68HDO/tuK2blj7ZcdEBRx3p1PjrgHazL8GYEpvULhrtGIFbQArN6cQS2QhW8mitffaB+VYzMjDqBxxQeoA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -7194,6 +7241,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hey-listen": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", + "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==", + "license": "MIT" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -10913,6 +10966,18 @@ "node": ">=8" } }, + "node_modules/popmotion": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-9.3.6.tgz", + "integrity": "sha512-ZTbXiu6zIggXzIliMi8LGxXBF5ST+wkpXGEjeTUDUOCdSQ356hij/xjeUdv0F8zCQNeqB1+PR5/BB+gC+QLAPw==", + "license": "MIT", + "dependencies": { + "framesync": "5.3.0", + "hey-listen": "^1.0.8", + "style-value-types": "4.1.4", + "tslib": "^2.1.0" + } + }, "node_modules/postcss": { "version": "8.4.47", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", @@ -11149,6 +11214,239 @@ "react": ">=18" } }, + "node_modules/react-material-ui-carousel": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/react-material-ui-carousel/-/react-material-ui-carousel-3.4.2.tgz", + "integrity": "sha512-jUbC5aBWqbbbUOOdUe3zTVf4kMiZFwKJqwhxzHgBfklaXQbSopis4iWAHvEOLcZtSIJk4JAGxKE0CmxDoxvUuw==", + "license": "MIT", + "dependencies": { + "@emotion/react": "^11.7.1", + "@emotion/styled": "^11.6.0", + "@mui/icons-material": "^5.4.1", + "@mui/material": "^5.4.1", + "@mui/system": "^5.4.1", + "framer-motion": "^4.1.17" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "@mui/icons-material": "^5.0.0", + "@mui/material": "^5.0.0", + "@mui/system": "^5.0.0", + "react": "^17.0.1 || ^18.0.0", + "react-dom": "^17.0.2 || ^18.0.0" + } + }, + "node_modules/react-material-ui-carousel/node_modules/@mui/core-downloads-tracker": { + "version": "5.16.7", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.16.7.tgz", + "integrity": "sha512-RtsCt4Geed2/v74sbihWzzRs+HsIQCfclHeORh5Ynu2fS4icIKozcSubwuG7vtzq2uW3fOR1zITSP84TNt2GoQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/react-material-ui-carousel/node_modules/@mui/icons-material": { + "version": "5.16.7", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.16.7.tgz", + "integrity": "sha512-UrGwDJCXEszbDI7yV047BYU5A28eGJ79keTCP4cc74WyncuVrnurlmIRxaHL8YK+LI1Kzq+/JM52IAkNnv4u+Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-material-ui-carousel/node_modules/@mui/material": { + "version": "5.16.7", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.16.7.tgz", + "integrity": "sha512-cwwVQxBhK60OIOqZOVLFt55t01zmarKJiJUWbk0+8s/Ix5IaUzAShqlJchxsIQ4mSrWqgcKCCXKtIlG5H+/Jmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/core-downloads-tracker": "^5.16.7", + "@mui/system": "^5.16.7", + "@mui/types": "^7.2.15", + "@mui/utils": "^5.16.6", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^18.3.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-material-ui-carousel/node_modules/@mui/private-theming": { + "version": "5.16.6", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.6.tgz", + "integrity": "sha512-rAk+Rh8Clg7Cd7shZhyt2HGTTE5wYKNSJ5sspf28Fqm/PZ69Er9o6KX25g03/FG2dfpg5GCwZh/xOojiTfm3hw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.16.6", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-material-ui-carousel/node_modules/@mui/styled-engine": { + "version": "5.16.6", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.6.tgz", + "integrity": "sha512-zaThmS67ZmtHSWToTiHslbI8jwrmITcN93LQaR2lKArbvS7Z3iLkwRoiikNWutx9MBs8Q6okKvbZq1RQYB3v7g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@emotion/cache": "^11.11.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/react-material-ui-carousel/node_modules/@mui/system": { + "version": "5.16.7", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.16.7.tgz", + "integrity": "sha512-Jncvs/r/d/itkxh7O7opOunTqbbSSzMTHzZkNLM+FjAOg+cYAZHrPDlYe1ZGKUYORwwb2XexlWnpZp0kZ4AHuA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.16.6", + "@mui/styled-engine": "^5.16.6", + "@mui/types": "^7.2.15", + "@mui/utils": "^5.16.6", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-material-ui-carousel/node_modules/@mui/utils": { + "version": "5.16.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.6.tgz", + "integrity": "sha512-tWiQqlhxAt3KENNiSRL+DIn9H5xNVK6Jjf70x3PnfQPz1MPBdh7yyIcAyVBT9xiw7hP3SomRhPR7hzBMBCjqEA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/types": "^7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^18.3.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -11999,6 +12297,16 @@ "inline-style-parser": "0.2.4" } }, + "node_modules/style-value-types": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-4.1.4.tgz", + "integrity": "sha512-LCJL6tB+vPSUoxgUBt9juXIlNJHtBMy8jkXzUJSBzeHWdBu6lhzHqCvLVkXFGsFIlNa2ln1sQHya/gzaFmB2Lg==", + "license": "MIT", + "dependencies": { + "hey-listen": "^1.0.8", + "tslib": "^2.1.0" + } + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index f819038096..2343fb3c71 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.0", + "react-material-ui-carousel": "^3.4.2", "react-router-dom": "^6.26.2", "react-toastify": "^10.0.5", "vite-plugin-svgr": "^4.2.0" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0091ec762f..2cb6704908 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,16 +1,18 @@ import { Routes, Route } from "react-router-dom"; -import Layout from "./components/Layout"; + import NewQuestion from "./pages/NewQuestion"; import QuestionDetail from "./pages/QuestionDetail"; import QuestionEdit from "./pages/QuestionEdit"; import PageNotFound from "./pages/PageNotFound"; import ProfilePage from "./pages/Profile"; -import AuthProvider from "./contexts/AuthContext"; import QuestionList from "./pages/QuestionList"; +import Landing from "./pages/Landing"; import Home from "./pages/Home"; import SignUp from "./pages/SignUp"; import LogIn from "./pages/LogIn"; import ProtectedRoutes from "./components/ProtectedRoutes"; +import Layout from "./components/Layout"; +import AuthProvider from "./contexts/AuthContext"; import ProfileContextProvider from "./contexts/ProfileContext"; function App() { @@ -18,7 +20,10 @@ function App() { }> - } /> + } /> + }> + } /> + } /> } /> diff --git a/frontend/src/components/ProtectedRoutes/index.tsx b/frontend/src/components/ProtectedRoutes/index.tsx index 6f314d415a..c8d30d9318 100644 --- a/frontend/src/components/ProtectedRoutes/index.tsx +++ b/frontend/src/components/ProtectedRoutes/index.tsx @@ -3,6 +3,7 @@ import { useAuth } from "../../contexts/AuthContext"; import React from "react"; import ServerError from "../ServerError"; import { USE_AUTH_ERROR_MESSAGE } from "../../utils/constants"; +import Loader from "../Loader"; type ProtectedRoutesProps = { adminOnly?: boolean; @@ -15,7 +16,11 @@ const ProtectedRoutes: React.FC = ({ if (!auth) { throw new Error(USE_AUTH_ERROR_MESSAGE); } - const { user } = auth; + const { user, loading } = auth; + + if (loading) { + return ; + } if (!user) { return ; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index f0d60a3899..ebbb8bde8e 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -25,11 +25,12 @@ type AuthContextType = { lastName: string, username: string, email: string, - password: string, + password: string ) => void; login: (email: string, password: string) => void; logout: () => void; user: User | null; + loading: boolean; }; const AuthContext = createContext(null); @@ -37,13 +38,12 @@ const AuthContext = createContext(null); const AuthProvider: React.FC<{ children?: React.ReactNode }> = (props) => { const { children } = props; const [user, setUser] = useState(null); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const navigate = useNavigate(); useEffect(() => { const accessToken = localStorage.getItem("token"); if (accessToken) { - setLoading(true); userClient .get("/auth/verify-token", { headers: { Authorization: `Bearer ${accessToken}` }, @@ -55,6 +55,7 @@ const AuthProvider: React.FC<{ children?: React.ReactNode }> = (props) => { }); } else { setUser(null); + setLoading(false); } }, []); @@ -63,7 +64,7 @@ const AuthProvider: React.FC<{ children?: React.ReactNode }> = (props) => { lastName: string, username: string, email: string, - password: string, + password: string ) => { userClient .post("/users", { @@ -90,7 +91,7 @@ const AuthProvider: React.FC<{ children?: React.ReactNode }> = (props) => { const { accessToken, user } = res.data.data; localStorage.setItem("token", accessToken); setUser(user); - navigate("/"); + navigate("/home"); }) .catch((err) => { setUser(null); @@ -110,7 +111,7 @@ const AuthProvider: React.FC<{ children?: React.ReactNode }> = (props) => { } return ( - + {children} ); diff --git a/frontend/src/pages/Home/index.tsx b/frontend/src/pages/Home/index.tsx index 353e0fc22d..0ef27dbcf9 100644 --- a/frontend/src/pages/Home/index.tsx +++ b/frontend/src/pages/Home/index.tsx @@ -22,7 +22,7 @@ import reducer, { initialState, } from "../../reducers/questionReducer"; import CustomChip from "../../components/CustomChip"; -import homepageImage from "/homepage_image.svg"; +// import homepageImage from "/homepage_image.svg"; const Home: React.FC = () => { const [complexity, setComplexity] = useState([]); diff --git a/frontend/src/pages/Landing/index.module.css b/frontend/src/pages/Landing/index.module.css new file mode 100644 index 0000000000..e9d75daecb --- /dev/null +++ b/frontend/src/pages/Landing/index.module.css @@ -0,0 +1,15 @@ +.fullheight { + flex: 1; +} + +.center { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.margins { + margin-top: 25px; + margin-bottom: 25px; +} diff --git a/frontend/src/pages/Landing/index.tsx b/frontend/src/pages/Landing/index.tsx new file mode 100644 index 0000000000..d33c7f4298 --- /dev/null +++ b/frontend/src/pages/Landing/index.tsx @@ -0,0 +1,40 @@ +import { Typography } from "@mui/material"; + +import classes from "./index.module.css"; +import AppMargin from "../../components/AppMargin"; + +const Landing: React.FC = () => { + return ( + + ({ + fontWeight: "bold", + color: "primary.main", + marginBottom: theme.spacing(4), + })} + > + Level up in your technical interviews! + + + ({ + fontSize: "h5.fontSize", + marginBottom: theme.spacing(4), + maxWidth: "80%", + })} + > + Your ultimate technical interview preparation platform to practice + whiteboard style interview questions with a peer. + + + ); +}; + +export default Landing; From c601878af36787c56a98c249e0d39f5dc8d3b002 Mon Sep 17 00:00:00 2001 From: jolynloh Date: Sat, 5 Oct 2024 17:12:52 +0800 Subject: [PATCH 62/78] Add landing page and modify navbar --- frontend/public/collaborative_editor.png | Bin 0 -> 30948 bytes frontend/public/find_match_form.png | Bin 0 -> 10866 bytes frontend/public/match_found.png | Bin 0 -> 12274 bytes frontend/public/questions_list.png | Bin 0 -> 18210 bytes frontend/src/components/Navbar/index.tsx | 37 +++++++++++------- frontend/src/pages/Home/index.module.css | 4 +- frontend/src/pages/Home/index.tsx | 5 +-- frontend/src/pages/Landing/index.module.css | 4 +- frontend/src/pages/Landing/index.tsx | 40 ++++++++++++++++++++ frontend/src/utils/constants.ts | 6 +++ 10 files changed, 76 insertions(+), 20 deletions(-) create mode 100644 frontend/public/collaborative_editor.png create mode 100644 frontend/public/find_match_form.png create mode 100644 frontend/public/match_found.png create mode 100644 frontend/public/questions_list.png diff --git a/frontend/public/collaborative_editor.png b/frontend/public/collaborative_editor.png new file mode 100644 index 0000000000000000000000000000000000000000..e84184e85c940e71461148f7b24cfa02f08a06a7 GIT binary patch literal 30948 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 literal 0 HcmV?d00001 diff --git a/frontend/public/find_match_form.png b/frontend/public/find_match_form.png new file mode 100644 index 0000000000000000000000000000000000000000..b515703a7f1fd655652c6ad7d731df92bcf8a194 GIT binary patch literal 10866 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 R=R>3bWd#lST3L&T{{a|lw$T6p literal 0 HcmV?d00001 diff --git a/frontend/public/match_found.png b/frontend/public/match_found.png new file mode 100644 index 0000000000000000000000000000000000000000..df60d6c39ae25c57d7df834f07fccfa279f8d1a0 GIT binary patch literal 12274 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 literal 0 HcmV?d00001 diff --git a/frontend/public/questions_list.png b/frontend/public/questions_list.png new file mode 100644 index 0000000000000000000000000000000000000000..381af8c26adaacb403797e5ffdc59a3e10fd4aa1 GIT binary patch literal 18210 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/Navbar/index.tsx b/frontend/src/components/Navbar/index.tsx index 4dc2e03c49..84176765d6 100644 --- a/frontend/src/components/Navbar/index.tsx +++ b/frontend/src/components/Navbar/index.tsx @@ -14,18 +14,27 @@ import { } from "@mui/material"; import { grey } from "@mui/material/colors"; import AppMargin from "../AppMargin"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import { useAuth } from "../../contexts/AuthContext"; import { useState } from "react"; import { USE_AUTH_ERROR_MESSAGE } from "../../utils/constants"; -type NavbarItem = { label: string; link: string }; +type NavbarItem = { label: string; link: string; needsLogin: boolean }; type NavbarProps = { navbarItems?: Array }; const Navbar: React.FC = (props) => { - const { navbarItems = [{ label: "Questions", link: "/questions" }] } = props; + const { + navbarItems = [ + { label: "Find Match", link: "/home", needsLogin: true }, + { label: "Questions", link: "/questions", needsLogin: false }, + ], + } = props; + const navigate = useNavigate(); + const location = useLocation(); + const path = location.pathname; + const auth = useAuth(); const [anchorEl, setAnchorEl] = useState(null); @@ -63,16 +72,18 @@ const Navbar: React.FC = (props) => { PeerPrep - {navbarItems.map((item) => ( - - {item.label} - - ))} + {navbarItems + .filter((item) => !item.needsLogin || (item.needsLogin && user)) + .map((item) => ( + + {path == item.link ? {item.label} : item.label} + + ))} {user ? ( <> diff --git a/frontend/src/pages/Home/index.module.css b/frontend/src/pages/Home/index.module.css index e9d75daecb..f43e28eee4 100644 --- a/frontend/src/pages/Home/index.module.css +++ b/frontend/src/pages/Home/index.module.css @@ -10,6 +10,6 @@ } .margins { - margin-top: 25px; - margin-bottom: 25px; + margin-top: 50px; + margin-bottom: 50px; } diff --git a/frontend/src/pages/Home/index.tsx b/frontend/src/pages/Home/index.tsx index 0ef27dbcf9..9167c1f05b 100644 --- a/frontend/src/pages/Home/index.tsx +++ b/frontend/src/pages/Home/index.tsx @@ -50,7 +50,7 @@ const Home: React.FC = () => { marginBottom: theme.spacing(4), })} > - Level up in your technical interviews! + Start an interactive practice session today! { maxWidth: "80%", })} > - Your ultimate technical interview preparation platform to practice - whiteboard style interview questions with a peer. + Specify your question preferences and sit back as we find you the best match. {/* { + const images = [ + { + name: "Questions list", + path: QUESTIONS_LIST_PATH, + }, + { + name: "Find match form", + path: FIND_MATCH_FORM_PATH, + }, + { + name: "Match found", + path: MATCH_FOUND_PATH, + }, + { + name: "Collaborative editor", + path: COLLABORATIVE_EDITOR_PATH, + }, + ]; + return ( { Your ultimate technical interview preparation platform to practice whiteboard style interview questions with a peer. + + + {images.map((image, i) => ( + + {image.name} + + ))} + ); }; diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 7f7b65bb13..8b381458e6 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -71,3 +71,9 @@ export const SUCCESS_PW_UPDATE_MESSAGE = "Password updated successfully"; export const FAILED_PW_UPDATE_MESSAGE = "Failed to update password"; export const SUCCESS_PROFILE_UPDATE_MESSAGE = "Profile updated successfully"; export const FAILED_PROFILE_UPDATE_MESSAGE = "Failed to update profile"; + +// Image paths +export const FIND_MATCH_FORM_PATH = "/find_match_form.png"; +export const MATCH_FOUND_PATH = "/match_found.png"; +export const QUESTIONS_LIST_PATH = "/questions_list.png"; +export const COLLABORATIVE_EDITOR_PATH = "/collaborative_editor.png"; From 2f01347612bcdbf47a49145d5cda3be8d7d0f213 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Sat, 5 Oct 2024 19:11:30 +0800 Subject: [PATCH 63/78] Add mongodb to docker, update readme --- README.md | 19 +++- backend/.env.sample | 16 ++++ .../GuideAssets/AddIPAddress.png | Bin .../GuideAssets/ConnectCluster.png | Bin .../GuideAssets/ConnectionString.png | Bin .../GuideAssets/Creation.png | Bin .../GuideAssets/DriverSelection.png | Bin .../GuideAssets/IPWhitelisting.png | Bin .../GuideAssets/Network.png | Bin .../GuideAssets/Security.png | Bin .../GuideAssets/Selection.png | Bin .../GuideAssets/Selection1.png | Bin .../GuideAssets/Selection2.png | Bin .../GuideAssets/Selection3.png | Bin .../GuideAssets/Selection4.png | Bin .../GuideAssets/SidePane.png | Bin backend/README.md | 88 ++++++++++++++++++ backend/question-service/.env.sample | 15 +-- backend/question-service/README.md | 40 ++------ backend/question-service/config/db.ts | 11 ++- backend/question-service/package.json | 2 +- .../src/controllers/questionController.ts | 1 - backend/question-service/tests/setup.ts | 5 + backend/user-service/.env.sample | 7 +- backend/user-service/MongoDBSetup.md | 61 ------------ backend/user-service/README.md | 36 ++----- backend/user-service/model/repository.ts | 5 +- backend/user-service/package.json | 3 +- backend/user-service/scripts/seed.ts | 20 ++-- backend/user-service/server.ts | 2 + backend/user-service/tests/setup.ts | 5 + docker-compose.yml | 30 ++++++ 32 files changed, 218 insertions(+), 148 deletions(-) create mode 100644 backend/.env.sample rename backend/{user-service => }/GuideAssets/AddIPAddress.png (100%) rename backend/{user-service => }/GuideAssets/ConnectCluster.png (100%) rename backend/{user-service => }/GuideAssets/ConnectionString.png (100%) rename backend/{user-service => }/GuideAssets/Creation.png (100%) rename backend/{user-service => }/GuideAssets/DriverSelection.png (100%) rename backend/{user-service => }/GuideAssets/IPWhitelisting.png (100%) rename backend/{user-service => }/GuideAssets/Network.png (100%) rename backend/{user-service => }/GuideAssets/Security.png (100%) rename backend/{user-service => }/GuideAssets/Selection.png (100%) rename backend/{user-service => }/GuideAssets/Selection1.png (100%) rename backend/{user-service => }/GuideAssets/Selection2.png (100%) rename backend/{user-service => }/GuideAssets/Selection3.png (100%) rename backend/{user-service => }/GuideAssets/Selection4.png (100%) rename backend/{user-service => }/GuideAssets/SidePane.png (100%) create mode 100644 backend/README.md delete mode 100644 backend/user-service/MongoDBSetup.md diff --git a/README.md b/README.md index 38f70711d4..f314273c74 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,19 @@ ## Group: G28 -### Note: +## Setting up -- You can choose to develop individual microservices within separate folders within this repository **OR** use individual repositories (all public) for each microservice. -- In the latter scenario, you should enable sub-modules on this GitHub classroom repository to manage the development/deployment **AND** add your mentor to the individual repositories as a collaborator. -- The teaching team should be given access to the repositories as we may require viewing the history of the repository in case of any disputes or disagreements. +We will be using Docker to set up PeerPrep. Install Docker [here](https://docs.docker.com/get-started/get-docker). + +Follow the instructions in the [backend directory](./backend/README.md) first before proceeding. + +Run: +``` +docker-compose up --build +``` + +## Useful links + +- User Service: http://localhost:3001 +- Question Service: http://localhost:3000 +- Frontend: http://localhost:5173 \ No newline at end of file diff --git a/backend/.env.sample b/backend/.env.sample new file mode 100644 index 0000000000..a0fef16d39 --- /dev/null +++ b/backend/.env.sample @@ -0,0 +1,16 @@ +# Credentials for MongoDB and Mongo Express. +# Create a copy of this file and name it `.env`. Change the values accordingly. + +# MongoDB credentials +MONGO_INITDB_ROOT_USERNAME=root +MONGO_INITDB_ROOT_PASSWORD=example + +# Mongo Express credentials +ME_CONFIG_BASICAUTH_USERNAME=admin +ME_CONFIG_BASICAUTH_PASSWORD=password + +# Do not change anything below this line +ME_CONFIG_MONGODB_ADMINUSERNAME=${MONGO_INITDB_ROOT_USERNAME} +ME_CONFIG_MONGODB_ADMINPASSWORD=${MONGO_INITDB_ROOT_PASSWORD} + +ME_CONFIG_MONGODB_URL=mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongo:27017/ diff --git a/backend/user-service/GuideAssets/AddIPAddress.png b/backend/GuideAssets/AddIPAddress.png similarity index 100% rename from backend/user-service/GuideAssets/AddIPAddress.png rename to backend/GuideAssets/AddIPAddress.png diff --git a/backend/user-service/GuideAssets/ConnectCluster.png b/backend/GuideAssets/ConnectCluster.png similarity index 100% rename from backend/user-service/GuideAssets/ConnectCluster.png rename to backend/GuideAssets/ConnectCluster.png diff --git a/backend/user-service/GuideAssets/ConnectionString.png b/backend/GuideAssets/ConnectionString.png similarity index 100% rename from backend/user-service/GuideAssets/ConnectionString.png rename to backend/GuideAssets/ConnectionString.png diff --git a/backend/user-service/GuideAssets/Creation.png b/backend/GuideAssets/Creation.png similarity index 100% rename from backend/user-service/GuideAssets/Creation.png rename to backend/GuideAssets/Creation.png diff --git a/backend/user-service/GuideAssets/DriverSelection.png b/backend/GuideAssets/DriverSelection.png similarity index 100% rename from backend/user-service/GuideAssets/DriverSelection.png rename to backend/GuideAssets/DriverSelection.png diff --git a/backend/user-service/GuideAssets/IPWhitelisting.png b/backend/GuideAssets/IPWhitelisting.png similarity index 100% rename from backend/user-service/GuideAssets/IPWhitelisting.png rename to backend/GuideAssets/IPWhitelisting.png diff --git a/backend/user-service/GuideAssets/Network.png b/backend/GuideAssets/Network.png similarity index 100% rename from backend/user-service/GuideAssets/Network.png rename to backend/GuideAssets/Network.png diff --git a/backend/user-service/GuideAssets/Security.png b/backend/GuideAssets/Security.png similarity index 100% rename from backend/user-service/GuideAssets/Security.png rename to backend/GuideAssets/Security.png diff --git a/backend/user-service/GuideAssets/Selection.png b/backend/GuideAssets/Selection.png similarity index 100% rename from backend/user-service/GuideAssets/Selection.png rename to backend/GuideAssets/Selection.png diff --git a/backend/user-service/GuideAssets/Selection1.png b/backend/GuideAssets/Selection1.png similarity index 100% rename from backend/user-service/GuideAssets/Selection1.png rename to backend/GuideAssets/Selection1.png diff --git a/backend/user-service/GuideAssets/Selection2.png b/backend/GuideAssets/Selection2.png similarity index 100% rename from backend/user-service/GuideAssets/Selection2.png rename to backend/GuideAssets/Selection2.png diff --git a/backend/user-service/GuideAssets/Selection3.png b/backend/GuideAssets/Selection3.png similarity index 100% rename from backend/user-service/GuideAssets/Selection3.png rename to backend/GuideAssets/Selection3.png diff --git a/backend/user-service/GuideAssets/Selection4.png b/backend/GuideAssets/Selection4.png similarity index 100% rename from backend/user-service/GuideAssets/Selection4.png rename to backend/GuideAssets/Selection4.png diff --git a/backend/user-service/GuideAssets/SidePane.png b/backend/GuideAssets/SidePane.png similarity index 100% rename from backend/user-service/GuideAssets/SidePane.png rename to backend/GuideAssets/SidePane.png diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000000..134346f3fe --- /dev/null +++ b/backend/README.md @@ -0,0 +1,88 @@ +# PeerPrep Backend + +> Set-up either a local or cloud MongoDB first, before proceeding to each microservice for more instructions. + +## Setting-up local MongoDB (only if you are using Docker) + +1. In the `backend` directory, create a copy of the `.env.sample` file and name it `.env`. + +2. To set up credentials for the MongoDB database, update `MONGO_INITDB_ROOT_USERNAME`, `MONGO_INITDB_ROOT_PASSWORD` of the `.env` file. + +3. Your local Mongo URI will be `mongodb://:@mongo:27017/`. Take note of it as we will be using in the `.env` file in the various microservices later on. + +5. You can view the MongoDB collections locally using Mongo Express. To set up Mongo Express, update `ME_CONFIG_BASICAUTH_USERNAME` and `ME_CONFIG_BASICAUTH_PASSWORD`. The username and password will be the login credentials when you access Mongo Express at http://localhost:8081. + +## Setting-up cloud MongoDB (in production) + +> This guide references the [user-service README in the PeerPrep-UserService repository](https://github.com/CS3219-AY2425S1/PeerPrep-UserService/blob/main/user-service/README.md) + +1. Visit the MongoDB Atlas Site [https://www.mongodb.com/atlas](https://www.mongodb.com/atlas) and click on "Try Free". + +2. Sign Up/Sign In with your preferred method. + +3. You will be greeted with welcome screens. Feel free to skip them till you reach the Dashboard page. + +4. Create a Database Deployment by clicking on the green `+ Create` Button: + +![alt text](./GuideAssets/Creation.png) + +5. Make selections as followings: + +- Select Shared Cluster +- Select `aws` as Provider + +![alt text](./GuideAssets/Selection1.png) + +- Select `Singapore` for Region + +![alt text](./GuideAssets/Selection2.png) + +- Select `M0 Sandbox` Cluster (Free Forever - No Card Required) + +> Ensure to select M0 Sandbox, else you may be prompted to enter card details and may be charged! + +![alt text](./GuideAssets/Selection3.png) + +- Leave `Additional Settings` as it is + +- Provide a suitable name to the Cluster + +![alt text](./GuideAssets/Selection4.png) + + +![alt text](./GuideAssets/Security.png) + +7. Next, click on `Add my Current IP Address`. This will whitelist your IP address and allow you to connect to the MongoDB Database. + +![alt text](./GuideAssets/Network.png) + +8. Click `Finish and Close` and the MongoDB Instance should be up and running. + +9. [Optional] Whitelisting All IP's + + 1. Select `Network Access` from the left side pane on Dashboard. + ![alt text](./GuideAssets/SidePane.png) + + 2. Click on the `Add IP Address` Button + ![alt text](./GuideAssets/AddIPAddress.png) + + 3. Select the `ALLOW ACCESS FROM ANYWHERE` Button and Click `Confirm` + ![alt text](./GuideAssets/IPWhitelisting.png) + + 4. Now, any IP Address can access this Database. + +10. After setting up, go to the Database Deployment Page. You would see a list of the Databases you have set up. Select `Connect` on the cluster you just created earlier. + + ![alt text](GuideAssets/ConnectCluster.png) + +11. Select the `Drivers` option. + + ![alt text](GuideAssets/DriverSelection.png) + +12. Select `Node.js` in the `Driver` pull-down menu, and copy the connection string. + + Notice, you may see `` in this connection string. We will be replacing this with the admin account password that we created earlier on when setting up the Shared Cluster. + + ![alt text](GuideAssets/ConnectionString.png) + +13. Your cloud Mongo URI will be the string you copied earlier. Take note of it as we will be using in the `.env` file in the various microservices later on. diff --git a/backend/question-service/.env.sample b/backend/question-service/.env.sample index abbae20e05..2108051616 100644 --- a/backend/question-service/.env.sample +++ b/backend/question-service/.env.sample @@ -1,10 +1,13 @@ -MONGO_URI=MONGO_URI +NODE_ENV=development -FIREBASE_PROJECT_ID=FIREBASE_PROJECT_ID -FIREBASE_PRIVATE_KEY=FIREBASE_PRIVATE_KEY -FIREBASE_CLIENT_EMAIL=FIREBASE_CLIENT_EMAIL -FIREBASE_STORAGE_BUCKET=FIREBASE_STORAGE_BUCKET +MONGO_CLOUD_URI= +MONGO_LOCAL_URI=mongodb://root:example@mongo:27017/ + +FIREBASE_PROJECT_ID= +FIREBASE_PRIVATE_KEY= +FIREBASE_CLIENT_EMAIL= +FIREBASE_STORAGE_BUCKET=>FIREBASE_STORAGE_BUCKET> ORIGINS=http://localhost:5173,http://127.0.0.1:5173 -USER_SERVICE_URL=USER_SERVICE_URL +USER_SERVICE_URL=http://user-service:3001/api diff --git a/backend/question-service/README.md b/backend/question-service/README.md index 4374fdab0d..062c42287c 100644 --- a/backend/question-service/README.md +++ b/backend/question-service/README.md @@ -1,30 +1,6 @@ # Question Service -> This guide references the [user-service README in the PeerPrep-UserService repository](https://github.com/CS3219-AY2425S1/PeerPrep-UserService/blob/main/user-service/README.md) - -## Setting-up MongoDB - -> :notebook: If you are familiar to MongoDB and wish to use a local instance, please feel free to do so. This guide utilizes MongoDB Cloud Services. - -1. Set up a MongoDB Shared Cluster by following the steps in this [Guide](../user-service/MongoDBSetup.md). - -2. After setting up, go to the Database Deployment Page. You would see a list of the Databases you have set up. Select `Connect` on the cluster you just created earlier. - - ![alt text](../user-service/GuideAssets/ConnectCluster.png) - -3. Select the `Drivers` option, as we have to link to a Node.js App (Question Service). - - ![alt text](../user-service/GuideAssets/DriverSelection.png) - -4. Select `Node.js` in the `Driver` pull-down menu, and copy the connection string. - - Notice, you may see `` in this connection string. We will be replacing this with the admin account password that we created earlier on when setting up the Shared Cluster. - - ![alt text](../user-service/GuideAssets/ConnectionString.png) - -5. In the `question-service` directory, create a copy of the `.env.sample` file and name it `.env`. - -6. Update the `MONGO_URI` of the `.env` file, and paste the string we copied earlier in step 4. +> If you have not set-up either a local or cloud MongoDB, go [here](../README.md) first before proceding. ## Setting-up Firebase @@ -52,11 +28,15 @@ 5. Go to `Settings`, `Project settings`, `Service accounts` and click `Generate new private key`. This will download a `.json` file, which will contain your credentials. -6. In `.env` of question service, replace: - - `FIREBASE_PROJECT_ID` with `project_id` found in the downloaded json file. - - `FIREBASE_PRIVATE_KEY` with `private_key` found in the downloaded json file. - - `FIREBASE_CLIENT_EMAIL` with `client_email` found in the downloaded json file. - - `FIREBASE_STORAGE_BUCKET` with the folder path of the Storage. It should look something like `gs://.appspot.com`. +## Setting-up Question Service + +1. In the `question-service` directory, create a copy of the `.env.sample` file and name it `.env`. + +2. Update `MONGO_CLOUD_URI`, `MONGO_LOCAL_URI`, `FIREBASE_PROJECT_ID`, `FIREBASE_PRIVATE_KEY`, `FIREBASE_CLIENT_EMAIL`, `FIREBASE_STORAGE_BUCKET`. + - `FIREBASE_PROJECT_ID` is the value of `project_id` found in the downloaded json file. + - `FIREBASE_PRIVATE_KEY` is the value of `private_key` found in the downloaded json file. + - `FIREBASE_CLIENT_EMAIL` is the value of `client_email` found in the downloaded json file. + - `FIREBASE_STORAGE_BUCKET` is the folder path of the Storage. It should look something like `gs://.appspot.com`. ## Running Question Service diff --git a/backend/question-service/config/db.ts b/backend/question-service/config/db.ts index 20ab0cfe25..f54e3758dd 100644 --- a/backend/question-service/config/db.ts +++ b/backend/question-service/config/db.ts @@ -5,11 +5,16 @@ dotenv.config(); const connectDB = async () => { try { - if (process.env.MONGO_URI == undefined) { - throw new Error("MONGO_URI is undefined"); + let mongoDBUri: string | undefined = + process.env.NODE_ENV === "production" + ? process.env.MONGO_CLOUD_URI + : process.env.MONGO_LOCAL_URI; + + if (!mongoDBUri) { + throw new Error("MongoDB URI is not provided"); } - await mongoose.connect(process.env.MONGO_URI); + await mongoose.connect(mongoDBUri); console.log("MongoDB connected"); } catch (error) { console.error(error); diff --git a/backend/question-service/package.json b/backend/question-service/package.json index 1970a6042a..de659b1995 100644 --- a/backend/question-service/package.json +++ b/backend/question-service/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "tsx server.ts", "dev": "tsx watch server.ts", - "test": "export NODE_ENV=test && jest", + "test": "set NODE_ENV=test && jest", "test:watch": "export NODE_ENV=test && jest --watch", "lint": "eslint ." }, diff --git a/backend/question-service/src/controllers/questionController.ts b/backend/question-service/src/controllers/questionController.ts index 879e653693..8e08403cc4 100644 --- a/backend/question-service/src/controllers/questionController.ts +++ b/backend/question-service/src/controllers/questionController.ts @@ -80,7 +80,6 @@ export const createImageLink = async ( const uploadPromises = files.map((file) => uploadFileToFirebase(file)); const imageUrls = await Promise.all(uploadPromises); - console.log(imageUrls); res .status(200) .json({ message: "Images uploaded successfully", imageUrls }); diff --git a/backend/question-service/tests/setup.ts b/backend/question-service/tests/setup.ts index 61da960bce..1c5e986392 100644 --- a/backend/question-service/tests/setup.ts +++ b/backend/question-service/tests/setup.ts @@ -6,6 +6,11 @@ let mongo: MongoMemoryServer; beforeAll(async () => { mongo = await MongoMemoryServer.create(); const mongoUri = mongo.getUri(); + + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + await mongoose.connect(mongoUri, {}); }); diff --git a/backend/user-service/.env.sample b/backend/user-service/.env.sample index 5e1c01c1fa..f5a9cf4c77 100644 --- a/backend/user-service/.env.sample +++ b/backend/user-service/.env.sample @@ -1,8 +1,11 @@ -DB_CLOUD_URI= +NODE_ENV=development PORT=3001 +MONGO_CLOUD_URI= +MONGO_LOCAL_URI= + # Secret for creating JWT signature -JWT_SECRET=you-can-replace-this-with-your-own-secret +JWT_SECRET= # admin default credentials ADMIN_FIRST_NAME=Admin diff --git a/backend/user-service/MongoDBSetup.md b/backend/user-service/MongoDBSetup.md deleted file mode 100644 index 60ec7d1622..0000000000 --- a/backend/user-service/MongoDBSetup.md +++ /dev/null @@ -1,61 +0,0 @@ -> This guide is taken from the [user-service MongoDBSetup.md in the PeerPrep-UserService repository](https://github.com/CS3219-AY2425S1/cs3219-ay2425s1-project-g28/blob/main/backend/user-service/MongoDBSetup.md) - -# Setting up MongoDB Instance - -1. Visit the MongoDB Atlas Site [https://www.mongodb.com/atlas](https://www.mongodb.com/atlas) and click on "Try Free" - -2. Sign Up/Sign In with your preferred method. - -3. You will be greeted with welcome screens. Feel free to skip them till you reach the Dashboard page. - -4. Create a Database Deployment by clicking on the green `+ Create` Button: - -![alt text](./GuideAssets/Creation.png) - -5. Make selections as followings: - -- Select Shared Cluster -- Select `aws` as Provider - -![alt text](./GuideAssets/Selection1.png) - -- Select `Singapore` for Region - -![alt text](./GuideAssets/Selection2.png) - -- Select `M0 Sandbox` Cluster (Free Forever - No Card Required) - -> Ensure to select M0 Sandbox, else you may be prompted to enter card details and may be charged! - -![alt text](./GuideAssets/Selection3.png) - -- Leave `Additional Settings` as it is - -- Provide a suitable name to the Cluster - -![alt text](./GuideAssets/Selection4.png) - - -![alt text](./GuideAssets/Security.png) - -7. Next, click on `Add my Current IP Address`. This will whitelist your IP address and allow you to connect to the MongoDB Database. - -![alt text](./GuideAssets/Network.png) - -8. Click `Finish and Close` and the MongoDB Instance should be up and running. - -## Whitelisting All IP's - -1. Select `Network Access` from the left side pane on Dashboard. - -![alt text](./GuideAssets/SidePane.png) - -2. Click on the `Add IP Address` Button - -![alt text](./GuideAssets/AddIPAddress.png) - -3. Select the `ALLOW ACCESS FROM ANYWHERE` Button and Click `Confirm` - -![alt text](./GuideAssets/IPWhitelisting.png) - -Now, any IP Address can access this Database. diff --git a/backend/user-service/README.md b/backend/user-service/README.md index 48292b55d1..abfd183f2c 100644 --- a/backend/user-service/README.md +++ b/backend/user-service/README.md @@ -1,32 +1,14 @@ # User Service Guide -> User Service was adapted from [PeerPrep-UserService repository](https://github.com/CS3219-AY2425S1/PeerPrep-UserService) +> If you have not set-up either a local or cloud MongoDB, go [here](../README.md) first before proceding. -> This guide references the [user-service README in the PeerPrep-UserService repository](https://github.com/CS3219-AY2425S1/PeerPrep-UserService/blob/main/user-service/README.md) +## Setting-up -## Setting-up MongoDB +1. In the `user-service` directory, create a copy of the `.env.sample` file and name it `.env`. -> :notebook: If you are familiar to MongoDB and wish to use a local instance, please feel free to do so. This guide utilizes MongoDB Cloud Services. +2. Update `MONGO_CLOUD_URI`, `MONGO_LOCAL_URI`, `JWT_SECRET`. -1. Set up a MongoDB Shared Cluster by following the steps in this [Guide](MongoDBSetup.md). - -2. After setting up, go to the Database Deployment Page. You would see a list of the Databases you have set up. Select `Connect` on the cluster you just created earlier. - - ![alt text](GuideAssets/ConnectCluster.png) - -3. Select the `Drivers` option, as we have to link to a Node.js App (User Service). - - ![alt text](GuideAssets/DriverSelection.png) - -4. Select `Node.js` in the `Driver` pull-down menu, and copy the connection string. - - Notice, you may see `` in this connection string. We will be replacing this with the admin account password that we created earlier on when setting up the Shared Cluster. - - ![alt text](GuideAssets/ConnectionString.png) - -5. In the `user-service` directory, create a copy of the `.env.sample` file and name it `.env`. - -6. Update the `DB_CLOUD_URI` of the `.env` file, and paste the string we copied earlier in step 4. +3. A default admin account (`email: admin@gmail.com` and `password: Admin@123`) wil be created. If you wish to change the default credentials, update them in `.env`. Alternatively, you can also edit your credentials and user profile after you have created the default account. ## Running User Service @@ -36,10 +18,8 @@ 3. Run the command: `npm install`. This will install all the necessary dependencies. -4. If you are running the user service for the first time with your own database, run `npm run seed`, to seed the database with a default admin account. If you wish to change the default, please update the `.env` file. Alternatively, you can also edit your credentials and user profile after you have created the default account. If you are using the .env file provided by us, the default admin account already exists in the database and you can skip this step. - -5. Run the command `npm start` to start the User Service in production mode, or use `npm run dev` for development mode, which includes features like automatic server restart when you make code changes. +4. Run the command `npm start` to start the User Service in production mode, or use `npm run dev` for development mode, which includes features like automatic server restart when you make code changes. -6. To view User Service documentation, go to http://localhost:3001/docs. +5. To view User Service documentation, go to http://localhost:3001/docs. -7. Using applications like Postman, you can interact with the User Service on port 3001. If you wish to change this, please update the `.env` file. +6. Using applications like Postman, you can interact with the User Service on port 3001. If you wish to change this, please update the `.env` file. diff --git a/backend/user-service/model/repository.ts b/backend/user-service/model/repository.ts index b08bd05c17..c7c7396c76 100644 --- a/backend/user-service/model/repository.ts +++ b/backend/user-service/model/repository.ts @@ -3,7 +3,10 @@ import "dotenv/config"; import { connect } from "mongoose"; export async function connectToDB() { - const mongoDBUri: string | undefined = process.env.DB_CLOUD_URI; + let mongoDBUri: string | undefined = + process.env.NODE_ENV === "production" + ? process.env.MONGO_CLOUD_URI + : process.env.MONGO_LOCAL_URI; if (!mongoDBUri) { throw new Error("MongoDB URI is not provided"); diff --git a/backend/user-service/package.json b/backend/user-service/package.json index 62a8afbc2b..1227250ad5 100644 --- a/backend/user-service/package.json +++ b/backend/user-service/package.json @@ -5,11 +5,10 @@ "main": "app.ts", "type": "module", "scripts": { - "seed": "tsx scripts/seed.ts", "start": "tsx server.ts", "dev": "tsx watch server.ts", "lint": "eslint .", - "test": "set NODE_ENV=test && jest", + "test": "export NODE_ENV=test && jest", "test:watch": "export NODE_ENV=test && jest --watch" }, "keywords": [], diff --git a/backend/user-service/scripts/seed.ts b/backend/user-service/scripts/seed.ts index ebc885b989..af456bb385 100644 --- a/backend/user-service/scripts/seed.ts +++ b/backend/user-service/scripts/seed.ts @@ -26,20 +26,22 @@ export async function seedAdminAccount() { const existingAdmin = await findUserByEmail(adminEmail); if (existingAdmin) { console.error("Admin account already exists in the database."); - process.exit(1); + return; } const salt = bcrypt.genSaltSync(10); const hashedPassword = bcrypt.hashSync(adminPassword, salt); - await createUser(adminFirstName, adminLastName, adminUsername, adminEmail, hashedPassword, true); - + await createUser( + adminFirstName, + adminLastName, + adminUsername, + adminEmail, + hashedPassword, + true + ); console.log("Admin account created successfully."); - process.exit(0); - } catch (err) { - console.error("Error seeding admin account:", err); - process.exit(1); + } catch { + console.error("Error creating admin account."); } } - -seedAdminAccount(); diff --git a/backend/user-service/server.ts b/backend/user-service/server.ts index c554d55c47..670ad03be2 100644 --- a/backend/user-service/server.ts +++ b/backend/user-service/server.ts @@ -2,6 +2,7 @@ import http from "http"; import index from "./app.ts"; import dotenv from "dotenv"; import { connectToDB } from "./model/repository"; +import { seedAdminAccount } from "./scripts/seed.ts"; dotenv.config(); @@ -13,6 +14,7 @@ if (process.env.NODE_ENV !== "test") { await connectToDB() .then(() => { console.log("MongoDB Connected!"); + seedAdminAccount(); server.listen(port); console.log("User service server listening on http://localhost:" + port); diff --git a/backend/user-service/tests/setup.ts b/backend/user-service/tests/setup.ts index 61da960bce..455506c382 100644 --- a/backend/user-service/tests/setup.ts +++ b/backend/user-service/tests/setup.ts @@ -6,6 +6,11 @@ let mongo: MongoMemoryServer; beforeAll(async () => { mongo = await MongoMemoryServer.create(); const mongoUri = mongo.getUri(); + + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + await mongoose.connect(mongoUri, {}); }); diff --git a/docker-compose.yml b/docker-compose.yml index 9cf2414f67..0760e8fa61 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,9 +5,12 @@ services: env_file: ./backend/user-service/.env ports: - 3001:3001 + depends_on: + - mongo networks: - peerprep-network restart: on-failure + question-service: image: peerprep/question-service build: ./backend/question-service @@ -15,11 +18,38 @@ services: ports: - 3000:3000 depends_on: + - mongo - user-service networks: - peerprep-network restart: on-failure + mongo: + image: mongo + restart: always + ports: + - 27017:27017 + networks: + - peerprep-network + volumes: + - mongo-data:/data/db + env_file: + - ./backend/.env + + mongo-express: + image: mongo-express + restart: always + ports: + - 8081:8081 + networks: + - peerprep-network + depends_on: + - mongo + env_file: ./backend/.env + +volumes: + mongo-data: + networks: peerprep-network: driver: bridge From 48bfd05e933ff9668c6ded3b958f31ef5c1e945a Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Sat, 5 Oct 2024 19:39:24 +0800 Subject: [PATCH 64/78] Fix env --- backend/question-service/app.ts | 5 ----- backend/question-service/config/firebase.ts | 3 +++ backend/question-service/server.ts | 18 +++++++++++++++--- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/backend/question-service/app.ts b/backend/question-service/app.ts index 3864f53fe3..594545818f 100644 --- a/backend/question-service/app.ts +++ b/backend/question-service/app.ts @@ -5,7 +5,6 @@ import yaml from "yaml"; import fs from "fs"; import cors from "cors"; -import connectDB from "./config/db.ts"; import questionRoutes from "./src/routes/questionRoutes.ts"; dotenv.config(); @@ -19,10 +18,6 @@ const swaggerDocument = yaml.parse(file); const app = express(); -if (process.env.NODE_ENV !== "test") { - connectDB(); -} - app.use(cors({ origin: allowedOrigins, credentials: true })); app.options("*", cors({ origin: allowedOrigins, credentials: true })); diff --git a/backend/question-service/config/firebase.ts b/backend/question-service/config/firebase.ts index 5ff9b8b053..c8eb5f4f76 100644 --- a/backend/question-service/config/firebase.ts +++ b/backend/question-service/config/firebase.ts @@ -1,4 +1,7 @@ import admin from "firebase-admin"; +import dotenv from "dotenv"; + +dotenv.config(); admin.initializeApp({ credential: admin.credential.cert({ diff --git a/backend/question-service/server.ts b/backend/question-service/server.ts index 5df6e350f2..af74cdeeb0 100644 --- a/backend/question-service/server.ts +++ b/backend/question-service/server.ts @@ -1,7 +1,19 @@ import app from "./app.ts"; +import connectDB from "./config/db.ts"; const PORT = process.env.PORT || 3000; -app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); -}); +if (process.env.NODE_ENV !== "test") { + connectDB() + .then(() => { + console.log("MongoDB connected"); + + app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); + }); + }) + .catch((err) => { + console.error("Failed to connect to DB"); + console.error(err); + }); +} From 273e2ff20ad5874c16138831b434dd14bb6ccf60 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Sat, 5 Oct 2024 20:10:32 +0800 Subject: [PATCH 65/78] Add seed for qns --- backend/question-service/package.json | 3 +- backend/question-service/src/scripts/seed.ts | 124 +++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 backend/question-service/src/scripts/seed.ts diff --git a/backend/question-service/package.json b/backend/question-service/package.json index de659b1995..8047683564 100644 --- a/backend/question-service/package.json +++ b/backend/question-service/package.json @@ -4,9 +4,10 @@ "main": "server.ts", "type": "module", "scripts": { + "seed": "tsx src/scripts/seed.ts", "start": "tsx server.ts", "dev": "tsx watch server.ts", - "test": "set NODE_ENV=test && jest", + "test": "export NODE_ENV=test && jest", "test:watch": "export NODE_ENV=test && jest --watch", "lint": "eslint ." }, diff --git a/backend/question-service/src/scripts/seed.ts b/backend/question-service/src/scripts/seed.ts new file mode 100644 index 0000000000..d8d56dd1d2 --- /dev/null +++ b/backend/question-service/src/scripts/seed.ts @@ -0,0 +1,124 @@ +import { exit } from "process"; +import connectDB from "../../config/db"; +import Question from "../models/Question"; + +export async function seedQuestions() { + await connectDB(); + + const questions = [ + { + title: "Serialize and Deserialize Binary Tree", + description: + "Design an algorithm to serialize and deserialize a binary tree. There is no restriction on how your serialization/deserialization algorithm should work. You just need to ensure that a binary tree can be serialized to a string and this string can be deserialized to the original tree structure. \n\n![image](https://firebasestorage.googleapis.com/v0/b/peerprep-c3bd1.appspot.com/o/07148757-21b2-4c20-93e0-d8bef1b3560d?alt=media)", + complexity: "Hard", + category: ["Tree", "Design"], + }, + { + title: "Two Sum", + description: + "Given an array of integers `nums` and an integer `target`, return indices of the two numbers such that they add up to `target`. You may assume that each input would have **exactly one solution**, and you may not use the same element twice. You can return the answer in any order.", + complexity: "Easy", + category: ["Array", "Hash Table"], + }, + { + title: "Add Two Numbers", + description: + "You are given two non-empty linked lists representing two non-negative integers. The digits are stored in **reverse order**, and each of their nodes contains a single digit. Add the two numbers and return the sum as a linked list. You may assume the two numbers do not contain any leading zero, except the number 0 itself.", + complexity: "Medium", + category: ["Linked List", "Math"], + }, + { + title: "Longest Substring Without Repeating Characters", + description: + "Given a string `s`, find the length of the **longest substring** without repeating characters.", + complexity: "Medium", + category: ["Hash Table", "Two Pointers", "String", "Sliding Window"], + }, + { + title: "Median of Two Sorted Arrays", + description: + "Given two sorted arrays `nums1` and `nums2` of size `m` and `n` respectively, return the median of the two sorted arrays.", + complexity: "Hard", + category: ["Array", "Binary Search", "Divide and Conquer"], + }, + { + title: "Longest Palindromic Substring", + description: + "Given a string `s`, return the **longest palindromic substring** in `s`.", + complexity: "Medium", + category: ["String", "Dynamic Programming"], + }, + { + title: "ZigZag Conversion", + description: + "The string `PAYPALISHIRING` is written in a zigzag pattern on a given number of rows like this: (you may want to display this pattern in a fixed font for better legibility) P A H N A P L S I I G Y I R And then read line by line: `PAHNAPLSIIGYIR` Write the code that will take a string and make this conversion given a number of rows.", + complexity: "Medium", + category: ["String"], + }, + { + title: "Reverse Integer", + description: + "Given a signed 32-bit integer `x`, return `x` with its digits reversed. If reversing `x` causes the value to go outside the signed 32-bit integer range `[-2^31, 2^31 - 1]`, then return 0.", + complexity: "Easy", + category: ["Math"], + }, + { + title: "String to Integer (atoi)", + description: + "Implement the `myAtoi(string s)` function, which converts a string to a 32-bit signed integer (similar to C/C++'s `atoi` function).", + complexity: "Medium", + category: ["Math", "String"], + }, + { + title: "Palindrome Number", + description: + "Given an integer `x`, return `true` if `x` is a palindrome integer. An integer is a palindrome when it reads the same backward as forward. For example, `121` is palindrome while `123` is not.", + complexity: "Easy", + category: ["Math"], + }, + { + title: "Regular Expression Matching", + description: + "Given an input string `s` and a pattern `p`, implement regular expression matching with support for `'.'` and `'*'` where: - `'.'` Matches any single character.​​​​ - `'*'` Matches zero or more of the preceding element.", + complexity: "Hard", + category: ["String", "Dynamic Programming", "Backtracking"], + }, + { + title: "Container With Most Water", + description: + "Given `n` non-negative integers `a1, a2, ..., an`, where each represents a point at coordinate `(i, ai)`. `n` vertical lines are drawn such that the two endpoints of the line `i` is at `(i, ai)` and `(i, 0)`. Find two lines, which, together with the x-axis forms a container, such that the container contains the most water.", + complexity: "Medium", + category: ["Array", "Two Pointers"], + }, + { + title: "Integer to Roman", + description: + "Roman numerals are represented by seven different symbols: `I`, `V`, `X`, `L`, `C`, `D` and `M`. Given an integer, convert it to a roman numeral.", + complexity: "Medium", + category: ["Math", "String"], + }, + { + title: "Roman to Integer", + description: + "Roman numerals are represented by seven different symbols: `I`, `V`, `X`, `L`, `C`, `D` and `M`. Given a roman numeral, convert it to an integer.", + complexity: "Easy", + category: ["Math", "String"], + }, + ]; + + try { + for (const qn of questions) { + const existingQn = await Question.findOne({ title: qn.title }); + if (existingQn) { + continue; + } + await Question.create(qn); + } + console.log("Questions seeded successfully."); + } catch { + console.error("Error creating questions."); + } + exit(); +} + +seedQuestions(); From 68941fbb562c1e985096749bf7213b70b70a64f1 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Sat, 5 Oct 2024 21:35:51 +0800 Subject: [PATCH 66/78] Update readme --- README.md | 2 +- backend/question-service/README.md | 10 ++++++---- backend/user-service/README.md | 10 ++++++---- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f314273c74..35a92f8d1d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ We will be using Docker to set up PeerPrep. Install Docker [here](https://docs.docker.com/get-started/get-docker). -Follow the instructions in the [backend directory](./backend/README.md) first before proceeding. +Follow the instructions in the [backend directory](./backend/) first before proceeding. Run: ``` diff --git a/backend/question-service/README.md b/backend/question-service/README.md index 062c42287c..fd4d0a341f 100644 --- a/backend/question-service/README.md +++ b/backend/question-service/README.md @@ -1,6 +1,6 @@ # Question Service -> If you have not set-up either a local or cloud MongoDB, go [here](../README.md) first before proceding. +> If you have not set-up either a local or cloud MongoDB, go [here](../) first before proceding. ## Setting-up Firebase @@ -38,7 +38,7 @@ - `FIREBASE_CLIENT_EMAIL` is the value of `client_email` found in the downloaded json file. - `FIREBASE_STORAGE_BUCKET` is the folder path of the Storage. It should look something like `gs://.appspot.com`. -## Running Question Service +## Running Question Service without Docker 1. Follow the instructions [here](https://nodejs.org/en/download/package-manager) to set up Node v20. @@ -48,6 +48,8 @@ 4. Run the command `npm start` to start the Question Service in production mode, or use `npm run dev` for development mode, which includes features like automatic server restart when you make code changes. -5. To view Question Service documentation, go to http://localhost:3000/docs. +## After running -6. Using applications like Postman, you can interact with the Question Service on port 3000. If you wish to change this, please update the `.env` file. +1. To view Question Service documentation, go to http://localhost:3000/docs. + +2. Using applications like Postman, you can interact with the Question Service on port 3000. If you wish to change this, please update the `.env` file. diff --git a/backend/user-service/README.md b/backend/user-service/README.md index abfd183f2c..873ea07950 100644 --- a/backend/user-service/README.md +++ b/backend/user-service/README.md @@ -1,6 +1,6 @@ # User Service Guide -> If you have not set-up either a local or cloud MongoDB, go [here](../README.md) first before proceding. +> If you have not set-up either a local or cloud MongoDB, go [here](../) first before proceding. ## Setting-up @@ -10,7 +10,7 @@ 3. A default admin account (`email: admin@gmail.com` and `password: Admin@123`) wil be created. If you wish to change the default credentials, update them in `.env`. Alternatively, you can also edit your credentials and user profile after you have created the default account. -## Running User Service +## Running User Service without Docker 1. Follow the instructions [here](https://nodejs.org/en/download/package-manager) to set up Node v20. @@ -20,6 +20,8 @@ 4. Run the command `npm start` to start the User Service in production mode, or use `npm run dev` for development mode, which includes features like automatic server restart when you make code changes. -5. To view User Service documentation, go to http://localhost:3001/docs. +## After running -6. Using applications like Postman, you can interact with the User Service on port 3001. If you wish to change this, please update the `.env` file. +1. To view User Service documentation, go to http://localhost:3001/docs. + +2. Using applications like Postman, you can interact with the User Service on port 3001. If you wish to change this, please update the `.env` file. From 63b3805c2186ffa5c2eab93a340741bc25a7ebe3 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Sat, 5 Oct 2024 21:53:46 +0800 Subject: [PATCH 67/78] Add instructions to seed qns --- README.md | 6 +----- backend/question-service/README.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 35a92f8d1d..d61c993554 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,4 @@ -[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](https://classroom.github.com/a/bzPrOe11) - -# CS3219 Project (PeerPrep) - AY2425S1 - -## Group: G28 +# CS3219 Project (PeerPrep) - AY2425S1 Group 28 ## Setting up diff --git a/backend/question-service/README.md b/backend/question-service/README.md index fd4d0a341f..c5e09ae826 100644 --- a/backend/question-service/README.md +++ b/backend/question-service/README.md @@ -48,6 +48,20 @@ 4. Run the command `npm start` to start the Question Service in production mode, or use `npm run dev` for development mode, which includes features like automatic server restart when you make code changes. +## Seeding questions into MongoDB + +1. With Docker + + - Run `docker ps` to get a list of the Docker containers on your machine. + + - Retrieve the `CONTAINER_ID` of `peerprep/question-service`. + + - Run `docker exec -it npm run seed`. + +2. Without Docker + + - Run `npm run seed`. + ## After running 1. To view Question Service documentation, go to http://localhost:3000/docs. From 996d12f7c5f0245dfef6084922e9caa98d2cb238 Mon Sep 17 00:00:00 2001 From: jolynloh Date: Sat, 5 Oct 2024 23:53:44 +0800 Subject: [PATCH 68/78] Write tests for create and update questions --- .../tests/questionRoutes.spec.ts | 265 ++++++++++++++++++ backend/question-service/tests/setup.ts | 7 +- 2 files changed, 271 insertions(+), 1 deletion(-) diff --git a/backend/question-service/tests/questionRoutes.spec.ts b/backend/question-service/tests/questionRoutes.spec.ts index 574743b23d..e538f57458 100644 --- a/backend/question-service/tests/questionRoutes.spec.ts +++ b/backend/question-service/tests/questionRoutes.spec.ts @@ -4,8 +4,12 @@ import supertest from "supertest"; import app from "../app"; import Question from "../src/models/Question"; import { + DUPLICATE_QUESTION_MESSAGE, + MONGO_OBJ_ID_MALFORMED_MESSAGE, PAGE_LIMIT_INCORRECT_FORMAT_MESSAGE, PAGE_LIMIT_REQUIRED_MESSAGE, + QN_DESC_CHAR_LIMIT, + QN_DESC_EXCEED_CHAR_LIMIT_MESSAGE, QN_NOT_FOUND_MESSAGE, SERVER_ERROR_MESSAGE, } from "../src/utils/constants"; @@ -166,4 +170,265 @@ describe("Question routes", () => { expect(res.body.message).toBe(QN_NOT_FOUND_MESSAGE); }); }); + + describe("POST /", () => { + it("Creates new question", async () => { + const title = faker.lorem.lines(1); + const complexity = "Easy"; + const categories = ["Algorithms"]; + const description = faker.lorem.lines(5); + const newQuestion = { + title, + complexity, + category: categories, + description, + }; + + const res = await request.post(`${BASE_URL}`).send(newQuestion); + + expect(res.status).toBe(201); + expect(res.body.question.title).toBe(title); + expect(res.body.question.complexity).toBe(complexity); + expect(res.body.question.categories).toEqual(categories); + expect(res.body.question.description).toBe(description); + }); + + it("Does not create duplicate questions (case-insensitive, upper case)", async () => { + const title = faker.lorem.lines(1); + const complexity = "Easy"; + const categories = ["Algorithms"]; + const description = faker.lorem.lines(5); + const newQuestion = new Question({ + title, + complexity, + category: categories, + description, + }); + + await newQuestion.save(); + + const duplicateTitle = title.toUpperCase(); + const duplicateQuestion = { + title: duplicateTitle, + complexity, + category: categories, + description, + }; + + const res = await request.post(`${BASE_URL}`).send(duplicateQuestion); + + expect(res.status).toBe(400); + expect(res.body.message).toBe(DUPLICATE_QUESTION_MESSAGE); + }); + + it("Does not create duplicate questions (case-insensitive, lower case)", async () => { + const title = faker.lorem.lines(1); + const complexity = "Easy"; + const categories = ["Algorithms"]; + const description = faker.lorem.lines(5); + const newQuestion = new Question({ + title, + complexity, + category: categories, + description, + }); + + await newQuestion.save(); + + const duplicateTitle = title.toLowerCase(); + const duplicateQuestion = { + title: duplicateTitle, + complexity, + category: categories, + description, + }; + + const res = await request.post(`${BASE_URL}`).send(duplicateQuestion); + + expect(res.status).toBe(400); + expect(res.body.message).toBe(DUPLICATE_QUESTION_MESSAGE); + }); + + it("Does not create questions that exceed the character limit", async () => { + const title = faker.lorem.lines(1); + const complexity = "Easy"; + const categories = ["Algorithms"]; + const description = faker.lorem.words(QN_DESC_CHAR_LIMIT + 5); + const newQuestion = { + title, + complexity, + category: categories, + description, + }; + + const res = await request.post(`${BASE_URL}`).send(newQuestion); + + expect(res.status).toBe(400); + expect(res.body.message).toBe(QN_DESC_EXCEED_CHAR_LIMIT_MESSAGE); + }); + }); + + describe("PUT /:id", () => { + it("Updates an existing question", async () => { + const title = faker.lorem.lines(1); + const complexity = "Easy"; + const categories = ["Algorithms"]; + const description = faker.lorem.lines(5); + const newQuestion = new Question({ + title, + complexity, + category: categories, + description, + }); + + await newQuestion.save(); + + const updatedTitle = title.toUpperCase(); + const updatedQuestion = { + title: updatedTitle, + complexity, + category: categories, + description, + }; + + const res = await request + .put(`${BASE_URL}/${newQuestion.id}`) + .send(updatedQuestion); + + expect(res.status).toBe(200); + expect(res.body.question.title).toBe(updatedTitle); + expect(res.body.question.complexity).toBe(complexity); + expect(res.body.question.categories).toEqual(categories); + expect(res.body.question.description).toBe(description); + }); + + it("Does not update non-existing question with invalid object id", async () => { + const title = faker.lorem.lines(1); + const complexity = "Easy"; + const categories = ["Algorithms"]; + const description = faker.lorem.lines(5); + const newQuestion = new Question({ + title, + complexity, + category: categories, + description, + }); + + await newQuestion.save(); + + const updatedCategories = ["Algorithms", "Brainteaser"]; + const updatedQuestion = { + title, + complexity, + category: updatedCategories, + description, + }; + + const res = await request.put(`${BASE_URL}/blah`).send(updatedQuestion); + + expect(res.status).toBe(400); + expect(res.body.message).toBe(MONGO_OBJ_ID_MALFORMED_MESSAGE); + }); + + it("Does not update non-existing question with valid object id", async () => { + const title = faker.lorem.lines(1); + const complexity = "Easy"; + const categories = ["Algorithms"]; + const description = faker.lorem.lines(5); + const newQuestion = new Question({ + title, + complexity, + category: categories, + description, + }); + + await newQuestion.save(); + + const updatedCategories = ["Algorithms", "Brainteaser"]; + const updatedQuestion = { + title, + complexity, + category: updatedCategories, + description, + }; + + const res = await request + .put(`${BASE_URL}/66f77e9f27ab3f794bdae664`) + .send(updatedQuestion); + + expect(res.status).toBe(404); + expect(res.body.message).toBe(QN_NOT_FOUND_MESSAGE); + }); + + it("Does not update an existing question if it causes a duplicate", async () => { + const title = faker.lorem.lines(1); + const complexity = "Easy"; + const categories = ["Algorithms"]; + const description = faker.lorem.lines(5); + const newQuestion = new Question({ + title, + complexity, + category: categories, + description, + }); + + await newQuestion.save(); + + const otherTitle = faker.lorem.lines(1); + const otherComplexity = "Medium"; + const otherCategories = ["String", "Data Structures"]; + const otherDescription = faker.lorem.lines(5); + const otherQuestion = new Question({ + title: otherTitle, + complexity: otherComplexity, + category: otherCategories, + description: otherDescription, + }); + + await otherQuestion.save(); + + const updatedQuestion = { + title: otherTitle.toLowerCase(), + complexity, + category: categories, + description, + }; + + const res = await request + .put(`${BASE_URL}/${newQuestion.id}`) + .send(updatedQuestion); + + expect(res.status).toBe(400); + expect(res.body.message).toBe(DUPLICATE_QUESTION_MESSAGE); + }); + + it("Does not update an existing question if it exceeds the character limit", async () => { + const title = faker.lorem.lines(1); + const complexity = "Easy"; + const categories = ["Algorithms"]; + const description = faker.lorem.lines(5); + const newQuestion = new Question({ + title, + complexity, + category: categories, + description, + }); + + await newQuestion.save(); + + const updatedQuestion = { + title, + complexity, + category: categories, + description: faker.lorem.words(QN_DESC_CHAR_LIMIT + 5), + }; + + const res = await request + .put(`${BASE_URL}/${newQuestion.id}`) + .send(updatedQuestion); + + expect(res.status).toBe(400); + expect(res.body.message).toBe(QN_DESC_EXCEED_CHAR_LIMIT_MESSAGE); + }); + }); }); diff --git a/backend/question-service/tests/setup.ts b/backend/question-service/tests/setup.ts index 61da960bce..2b53eb8ac6 100644 --- a/backend/question-service/tests/setup.ts +++ b/backend/question-service/tests/setup.ts @@ -6,7 +6,12 @@ let mongo: MongoMemoryServer; beforeAll(async () => { mongo = await MongoMemoryServer.create(); const mongoUri = mongo.getUri(); - await mongoose.connect(mongoUri, {}); + + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + + mongoose.connect(mongoUri, {}); }); afterEach(async () => { From 1f134acbf94291fd8109b43253dd0d14b7b6a51d Mon Sep 17 00:00:00 2001 From: feliciagan <85786249+feliciagan@users.noreply.github.com> Date: Sun, 6 Oct 2024 02:56:01 +0800 Subject: [PATCH 69/78] add edit profile picture - add coverage folder to dockerignore - update backend services swagger yml for images api - update user service readme to include firebase setup - add edit profile picture functionality --- backend/question-service/.dockerignore | 1 + .../src/controllers/questionController.ts | 4 +- backend/question-service/swagger.yml | 43 + backend/user-service/.env.sample | 6 + backend/user-service/README.md | 32 + backend/user-service/config/firebase.ts | 14 + backend/user-service/config/multer.ts | 6 + .../controller/user-controller.ts | 30 + backend/user-service/package-lock.json | 1415 ++++++++++++++++- backend/user-service/package.json | 5 + backend/user-service/routes/user-routes.ts | 3 + backend/user-service/swagger.yml | 41 + backend/user-service/utils/utils.ts | 51 + .../src/components/EditProfileModal/index.tsx | 146 +- frontend/src/components/Navbar/index.tsx | 2 +- .../src/components/ProfileDetails/index.tsx | 7 +- frontend/src/contexts/ProfileContext.tsx | 22 +- frontend/src/pages/Profile/index.tsx | 2 + frontend/src/utils/constants.ts | 4 + frontend/src/utils/validators.ts | 9 + 20 files changed, 1774 insertions(+), 69 deletions(-) create mode 100644 backend/user-service/config/firebase.ts create mode 100644 backend/user-service/config/multer.ts create mode 100644 backend/user-service/utils/utils.ts diff --git a/backend/question-service/.dockerignore b/backend/question-service/.dockerignore index 7ee74d25c3..4abc77f632 100644 --- a/backend/question-service/.dockerignore +++ b/backend/question-service/.dockerignore @@ -1,3 +1,4 @@ +coverage node_modules tests .env* diff --git a/backend/question-service/src/controllers/questionController.ts b/backend/question-service/src/controllers/questionController.ts index 879e653693..fcc2115b9b 100644 --- a/backend/question-service/src/controllers/questionController.ts +++ b/backend/question-service/src/controllers/questionController.ts @@ -81,11 +81,11 @@ export const createImageLink = async ( const uploadPromises = files.map((file) => uploadFileToFirebase(file)); const imageUrls = await Promise.all(uploadPromises); console.log(imageUrls); - res + return res .status(200) .json({ message: "Images uploaded successfully", imageUrls }); } catch (error) { - res.status(500).json({ message: "Server error", error }); + return res.status(500).json({ message: "Server error", error }); } }); }; diff --git a/backend/question-service/swagger.yml b/backend/question-service/swagger.yml index 2d2fc6b0b3..9032569b0b 100644 --- a/backend/question-service/swagger.yml +++ b/backend/question-service/swagger.yml @@ -350,3 +350,46 @@ paths: application/json: schema: $ref: "#/definitions/ServerError" + /api/questions/images: + post: + summary: Publish image to firebase storage + tags: + - questions + security: + - bearerAuth: [] + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + profilePic: + type: string + format: binary + required: true + responses: + 200: + description: Successful Response + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: Message + imageUrl: + type: string + description: image url + 400: + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 500: + description: Internal Server Error + content: + application/json: + schema: + $ref: "#/components/schemas/ServerErrorResponse" diff --git a/backend/user-service/.env.sample b/backend/user-service/.env.sample index 5e1c01c1fa..afc74a9217 100644 --- a/backend/user-service/.env.sample +++ b/backend/user-service/.env.sample @@ -11,5 +11,11 @@ ADMIN_USERNAME=administrator ADMIN_EMAIL=admin@gmail.com ADMIN_PASSWORD=Admin@123 +# firebase +FIREBASE_PROJECT_ID=FIREBASE_PROJECT_ID +FIREBASE_PRIVATE_KEY=FIREBASE_PRIVATE_KEY +FIREBASE_CLIENT_EMAIL=FIREBASE_CLIENT_EMAIL +FIREBASE_STORAGE_BUCKET=FIREBASE_STORAGE_BUCKET + # origins for cors ORIGINS=http://localhost:5173,http://127.0.0.1:5173 \ No newline at end of file diff --git a/backend/user-service/README.md b/backend/user-service/README.md index 48292b55d1..f85acbfdc3 100644 --- a/backend/user-service/README.md +++ b/backend/user-service/README.md @@ -28,6 +28,38 @@ 6. Update the `DB_CLOUD_URI` of the `.env` file, and paste the string we copied earlier in step 4. +## Setting-up Firebase + +1. Go to https://console.firebase.google.com/u/0/. + +2. Create a project and choose a project name. Navigate to `Storage` and click on it to activate it. + +3. Select `Start in production mode` and your preferred cloud storage region. + +4. After Storage is created, go to `Rules` section and set rule to: + + ``` + rules_version = '2'; + service firebase.storage { + match /b/{bucket}/o { + match /{allPaths=**} { + allow read: if true; + allow write: if request.auth != null; + } + } + } + ``` + + This rule ensures that only verified users can upload images while ensuring that URLs of images are public. Remember to click `Publish` to save changes. + +5. Go to `Settings`, `Project settings`, `Service accounts` and click `Generate new private key`. This will download a `.json` file, which will contain your credentials. + +6. In `.env` of user service, replace: + - `FIREBASE_PROJECT_ID` with `project_id` found in the downloaded json file. + - `FIREBASE_PRIVATE_KEY` with `private_key` found in the downloaded json file. + - `FIREBASE_CLIENT_EMAIL` with `client_email` found in the downloaded json file. + - `FIREBASE_STORAGE_BUCKET` with the folder path of the Storage. It should look something like `gs://.appspot.com`. + ## Running User Service 1. Follow the instructions [here](https://nodejs.org/en/download/package-manager) to set up Node v20. diff --git a/backend/user-service/config/firebase.ts b/backend/user-service/config/firebase.ts new file mode 100644 index 0000000000..5ff9b8b053 --- /dev/null +++ b/backend/user-service/config/firebase.ts @@ -0,0 +1,14 @@ +import admin from "firebase-admin"; + +admin.initializeApp({ + credential: admin.credential.cert({ + projectId: process.env.FIREBASE_PROJECT_ID, + privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"), + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + } as admin.ServiceAccount), + storageBucket: process.env.FIREBASE_STORAGE_BUCKET, +}); + +const bucket = admin.storage().bucket(); + +export { bucket }; diff --git a/backend/user-service/config/multer.ts b/backend/user-service/config/multer.ts new file mode 100644 index 0000000000..52e67fc3b5 --- /dev/null +++ b/backend/user-service/config/multer.ts @@ -0,0 +1,6 @@ +import multer from "multer"; + +const storage = multer.memoryStorage(); +const upload = multer({ storage }).single("profilePic"); + +export { upload }; diff --git a/backend/user-service/controller/user-controller.ts b/backend/user-service/controller/user-controller.ts index 654f2fd104..72c7866ab2 100644 --- a/backend/user-service/controller/user-controller.ts +++ b/backend/user-service/controller/user-controller.ts @@ -20,6 +20,8 @@ import { validateBiography, } from "../utils/validators"; import { IUser } from "../model/user-model"; +import { upload } from "../config/multer"; +import { uploadFileToFirebase } from "../utils/utils"; export async function createUser( req: Request, @@ -92,6 +94,34 @@ export async function createUser( } } +export const createImageLink = async ( + req: Request, + res: Response +): Promise => { + upload(req, res, async (err) => { + if (err) { + return res + .status(500) + .json({ message: "Failed to upload image", error: err.message }); + } + + if (!req.file) { + return res.status(400).json({ message: "No image uploaded" }); + } + + try { + const file = req.file as Express.Multer.File; + const imageUrl = await uploadFileToFirebase("profilePics/", file); + + return res + .status(200) + .json({ message: "Image uploaded successfully", imageUrl: imageUrl }); + } catch (error) { + return res.status(500).json({ message: "Server error", error }); + } + }); +}; + export async function getUser(req: Request, res: Response): Promise { try { const userId = req.params.id; diff --git a/backend/user-service/package-lock.json b/backend/user-service/package-lock.json index aa8cb73577..03c555b9d0 100644 --- a/backend/user-service/package-lock.json +++ b/backend/user-service/package-lock.json @@ -14,9 +14,12 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "firebase-admin": "^12.6.0", "jsonwebtoken": "^9.0.2", "mongoose": "^8.5.4", + "multer": "^1.4.5-lts.1", "swagger-ui-express": "^5.0.1", + "uuid": "^10.0.0", "validator": "^13.12.0", "yaml": "^2.5.1" }, @@ -27,9 +30,11 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.13", "@types/jsonwebtoken": "^9.0.7", + "@types/multer": "^1.4.12", "@types/node": "^22.5.5", "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.6", + "@types/uuid": "^10.0.0", "@types/validator": "^13.12.2", "eslint": "^9.11.1", "globals": "^15.9.0", @@ -1316,6 +1321,231 @@ "npm": ">=9.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.0.0.tgz", + "integrity": "sha512-83rnH2nCvclWaPQQKvkJ2pdOjG4TZyEVuFDnlOF6KP08lDaaceVyw/W63mDuafQT+MKHCvXIPpE5uYWeM0rT4w==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.2.tgz", + "integrity": "sha512-LMs47Vinv2HBMZi49C09dJxp0QT5LwDzFaVGf/+ITHe3BlIhUiLNttkATSXplc89A2lAaeTqjgqVkiRfUGyQiQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.2.tgz", + "integrity": "sha512-oMEZ1TDlBz479lmABwWsWjzHwheQKiAgnuKxE0pz0IXCVx7/rtlkx1fQ6GfgK24WCrxDKMplZrT50Kh04iMbXQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.3.tgz", + "integrity": "sha512-Fc9wuJGgxoxQeavybiuwgyi+0rssr76b+nHpj+eGhXFYAdudMWyfBHvFL/I5fEHniUM/UQdFzi9VXJK2iZF7FQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.9.tgz", + "integrity": "sha512-gm8EUEJE/fEac86AvHn8Z/QW8BvR56TBw3hMW0O838J/1mThYQXAIQBgUv75EqlCZfdawpWLrKt1uXvp9ciK3Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.8.tgz", + "integrity": "sha512-dzXALZeBI1U5TXt6619cv0+tgEhJiwlUtQ55WNZY7vGAjv7Q1QioV969iYwt1AQQ0ovHnEW0YW9TiBfefLvErg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.2", + "@firebase/auth-interop-types": "0.2.3", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-1.0.8.tgz", + "integrity": "sha512-OpeWZoPE3sGIRPBKYnW9wLad25RaWbGyk7fFQe4xnJQKRzlynWeFBSRRAoLE2Old01WXwskUiucNqUUVlFsceg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/database": "1.0.8", + "@firebase/database-types": "1.0.5", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.5.tgz", + "integrity": "sha512-fTlqCNwFYyq/C6W7AJ5OCuq5CeZuBEsEwptnVxlNPkWCo5cTTyukzAHRSO/jaQcItz33FfYrrFk1SJofcu2AaQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.2", + "@firebase/util": "1.10.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.2.tgz", + "integrity": "sha512-Q1VuA5M1Gjqrwom6I6NUU4lQXdo9IAQieXlujeHZWvRt1b7qQ0KwBaNAjgxG27jgF9/mUwsNmO8ptBCGVYhB0A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.10.0.tgz", + "integrity": "sha512-xKtx4A668icQqoANRxyDLBLz51TAbDP9KRfpbKGxiCAW346d0BeJe5vN6/hKxxmWwnZ0mautyv39JxviwwQMOQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.10.0.tgz", + "integrity": "sha512-VFNhdHvfnmqcHHs6YhmSNHHxQqaaD64GwiL0c+e1qz85S8SWZPC2XFRf8p9yHRTF40Kow424s1KBU9f0fdQa+Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.13.0.tgz", + "integrity": "sha512-Y0rYdwM5ZPW3jw/T26sMxxfPrVQTKm9vGrZG8PRyGuUmUJ8a2xNuQ9W/NNA1prxqv2i54DSydV8SJqxF2oCVgA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "^4.0.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^4.4.1", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.0.tgz", + "integrity": "sha512-eWdP97A6xKtZXVP/ze9y8zYRB2t6ugQAuLXFuZXAsyqmyltaAjl4yPkmIfc0wuTFJMOUF1AdvIFQCL7fMtaX6g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.7.13", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.13.tgz", + "integrity": "sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1806,6 +2036,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", @@ -1871,6 +2112,90 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1898,6 +2223,16 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -1984,17 +2319,22 @@ "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, "dependencies": { "@types/connect": "*", "@types/node": "*" } }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -2026,7 +2366,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "dev": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -2038,7 +2377,6 @@ "version": "4.19.5", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz", "integrity": "sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==", - "dev": true, "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -2059,8 +2397,7 @@ "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -2111,11 +2448,17 @@ "version": "9.0.7", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz", "integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==", - "dev": true, "dependencies": { "@types/node": "*" } }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2126,14 +2469,22 @@ "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" + }, + "node_modules/@types/multer": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.12.tgz", + "integrity": "sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } }, "node_modules/@types/node": { "version": "22.5.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.5.tgz", "integrity": "sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==", - "dev": true, "dependencies": { "undici-types": "~6.19.2" } @@ -2141,20 +2492,45 @@ "node_modules/@types/qs": { "version": "6.9.16", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", - "integrity": "sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==", - "dev": true + "integrity": "sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + }, + "node_modules/@types/request": { + "version": "2.48.12", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.12.tgz", + "integrity": "sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + } + }, + "node_modules/@types/request/node_modules/form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } }, "node_modules/@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, "dependencies": { "@types/mime": "^1", "@types/node": "*" @@ -2164,7 +2540,6 @@ "version": "1.15.7", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, "dependencies": { "@types/http-errors": "*", "@types/node": "*", @@ -2213,6 +2588,20 @@ "@types/serve-static": "*" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/validator": { "version": "13.12.2", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.2.tgz", @@ -2558,6 +2947,19 @@ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2683,7 +3085,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2708,6 +3110,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -2745,6 +3153,16 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -2769,11 +3187,21 @@ "tslib": "^2.4.0" } }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/b4a": { @@ -2922,22 +3350,53 @@ "license": "Apache-2.0", "optional": true }, - "node_modules/bcrypt": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", - "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", - "hasInstallScript": true, - "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.11", - "node-addon-api": "^5.0.0" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, "engines": { @@ -3074,9 +3533,19 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3253,7 +3722,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -3286,7 +3755,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3299,7 +3768,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/color-support": { @@ -3314,7 +3783,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -3345,6 +3814,51 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -3396,6 +3910,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -3512,7 +4032,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -3600,6 +4120,19 @@ "url": "https://dotenvx.com" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3662,6 +4195,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -3734,7 +4277,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -3975,6 +4518,16 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -4066,11 +4619,27 @@ "node": ">= 0.10.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT", + "optional": true + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fast-fifo": { @@ -4118,6 +4687,29 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz", + "integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -4128,6 +4720,18 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -4248,6 +4852,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase-admin": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-12.6.0.tgz", + "integrity": "sha512-gc0pDiUmxscxBhcjMcttmjvExJmnQdVRb+IIth95CvMm7F9rLdabrQZThW2mK02HR696P+rzd6NqkdUA3URu4w==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^1.0.2", + "@firebase/database-types": "^1.0.0", + "@types/node": "^22.0.1", + "farmhash-modern": "^1.1.0", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.7.0", + "@google-cloud/storage": "^7.7.0" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -4385,6 +5013,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, "node_modules/gauge": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", @@ -4405,6 +5040,121 @@ "node": ">=10" } }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", + "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "gaxios": "^5.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", + "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4419,7 +5169,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4523,6 +5273,99 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "9.14.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.14.1.tgz", + "integrity": "sha512-Rj+PMjoNFGFTmtItH7gHfbHpGVSb3vmnGK3nwNBqxQF9NoBpttSZI/rc0WiM63ma2uGDQtYEkMHkK9U6937NiA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/gcp-metadata": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz", + "integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-auth-library/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "optional": true, + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-gax": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.4.1.tgz", + "integrity": "sha512-Phyp9fMfA00J3sZbJxbbB4jC55b7DBjE3F6poyL3wKMEBVKA79q6BGuHcTiM28yOzVql0NDbRL8MLLh8Iwk9Dg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -4548,6 +5391,43 @@ "dev": true, "license": "MIT" }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gtoken/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/gtoken/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "optional": true, + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -4612,9 +5492,26 @@ "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" - } + "engines": { + "node": ">=8" + } + }, + "node_modules/html-entities": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", + "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true }, "node_modules/html-escaper": { "version": "2.0.2", @@ -4638,6 +5535,52 @@ "node": ">= 0.8" } }, + "node_modules/http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -4875,7 +5818,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -4884,6 +5827,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5638,6 +6587,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5671,6 +6629,16 @@ "node": ">=4" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -5748,6 +6716,46 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwks-rsa": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.1.0.tgz", + "integrity": "sha512-v7nqlfezb9YfHHzYII3ef2a2j1XnGeSE/bK3WfumaYCqONAIstJbrEGapz4kadScZzEt7zYCN7bucj8C0Mv/Rg==", + "license": "MIT", + "dependencies": { + "@types/express": "^4.17.17", + "@types/jsonwebtoken": "^9.0.2", + "debug": "^4.3.4", + "jose": "^4.14.6", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/jws": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", @@ -5809,6 +6817,11 @@ "node": ">= 0.8.0" } }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -5832,6 +6845,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -5881,6 +6907,13 @@ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5898,6 +6931,28 @@ "dev": true, "license": "ISC" }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/lru-memoizer/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -6048,6 +7103,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -6319,6 +7383,36 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/multer": { + "version": "1.4.5-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", + "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/multer/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -6415,6 +7509,15 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -6536,6 +7639,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", @@ -6604,7 +7717,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -6865,6 +7978,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -6879,6 +7998,44 @@ "node": ">= 6" } }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7022,7 +8179,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7098,6 +8255,31 @@ "node": ">=10" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -7405,6 +8587,31 @@ "node": ">= 0.8" } }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/streamx": { "version": "2.20.1", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.20.1.tgz", @@ -7499,6 +8706,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", + "license": "MIT", + "optional": true + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, "node_modules/superagent": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz", @@ -7646,6 +8867,37 @@ "streamx": "^2.15.0" } }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -7845,7 +9097,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "dev": true, "license": "0BSD" }, "node_modules/tsx": { @@ -7915,6 +9166,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", @@ -7961,8 +9218,7 @@ "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, "node_modules/unpipe": { "version": "1.0.0", @@ -8026,6 +9282,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -8082,6 +9351,29 @@ "node": ">=12" } }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/whatwg-url": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz", @@ -8132,7 +9424,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -8165,11 +9457,20 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=10" @@ -8196,7 +9497,7 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -8215,7 +9516,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=12" @@ -8249,7 +9550,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/backend/user-service/package.json b/backend/user-service/package.json index 62a8afbc2b..64305c1631 100644 --- a/backend/user-service/package.json +++ b/backend/user-service/package.json @@ -22,9 +22,11 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.13", "@types/jsonwebtoken": "^9.0.7", + "@types/multer": "^1.4.12", "@types/node": "^22.5.5", "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.6", + "@types/uuid": "^10.0.0", "@types/validator": "^13.12.2", "eslint": "^9.11.1", "globals": "^15.9.0", @@ -44,9 +46,12 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "firebase-admin": "^12.6.0", "jsonwebtoken": "^9.0.2", "mongoose": "^8.5.4", + "multer": "^1.4.5-lts.1", "swagger-ui-express": "^5.0.1", + "uuid": "^10.0.0", "validator": "^13.12.0", "yaml": "^2.5.1" } diff --git a/backend/user-service/routes/user-routes.ts b/backend/user-service/routes/user-routes.ts index 10c5016b18..b2416b645f 100644 --- a/backend/user-service/routes/user-routes.ts +++ b/backend/user-service/routes/user-routes.ts @@ -1,6 +1,7 @@ import express from "express"; import { + createImageLink, createUser, deleteUser, getAllUsers, @@ -27,6 +28,8 @@ router.patch( router.post("/", createUser); +router.post("/images", createImageLink); + router.get("/:id", getUser); router.patch("/:id", verifyAccessToken, verifyIsOwnerOrAdmin, updateUser); diff --git a/backend/user-service/swagger.yml b/backend/user-service/swagger.yml index 9d42c7709f..ce0baa9257 100644 --- a/backend/user-service/swagger.yml +++ b/backend/user-service/swagger.yml @@ -292,6 +292,47 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + /api/users/images: + post: + summary: Publish image to firebase storage + tags: + - users + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + profilePic: + type: string + format: binary + required: true + responses: + 200: + description: Successful Response + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: Message + imageUrl: + type: string + description: image url + 400: + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 500: + description: Internal Server Error + content: + application/json: + schema: + $ref: "#/components/schemas/ServerErrorResponse" /api/auth/login: post: summary: Login diff --git a/backend/user-service/utils/utils.ts b/backend/user-service/utils/utils.ts new file mode 100644 index 0000000000..ddf2012e08 --- /dev/null +++ b/backend/user-service/utils/utils.ts @@ -0,0 +1,51 @@ +import { v4 as uuidv4 } from "uuid"; +import { bucket } from "../config/firebase"; + +export const uploadFileToFirebase = async ( + folderName: string, + file: Express.Multer.File +): Promise => { + return new Promise((resolve, reject) => { + const fileName = folderName + uuidv4(); + const ref = bucket.file(fileName); + + const blobStream = ref.createWriteStream({ + metadata: { + contentType: file.mimetype, + }, + }); + + blobStream.on("error", (error) => { + reject(error); + }); + + blobStream.on("finish", async () => { + try { + await ref.makePublic(); + resolve(`https://storage.googleapis.com/${bucket.name}/${fileName}`); + } catch (error) { + reject(error); + } + }); + + blobStream.end(file.buffer); + }); +}; + +/*export const deleteFileFromFirebase = async ( + fileUrl: string +): Promise => { + return new Promise((resolve, reject) => { + const fileName = fileUrl.split('/o/')[1].split('?')[0].replace(/%2F/g, '/'); + const ref = bucket.file(fileName); + + async () => { + try { + await ref.delete(); + resolve("File deleted"); + } catch (error) { + reject(error); + } + } + }) +};*/ diff --git a/frontend/src/components/EditProfileModal/index.tsx b/frontend/src/components/EditProfileModal/index.tsx index d35d09c56e..fb66aa8f5f 100644 --- a/frontend/src/components/EditProfileModal/index.tsx +++ b/frontend/src/components/EditProfileModal/index.tsx @@ -1,21 +1,29 @@ import { + Avatar, Button, Container, Dialog, DialogContent, DialogTitle, + IconButton, Stack, styled, TextField, + Typography, } from "@mui/material"; +import DeleteIcon from '@mui/icons-material/Delete'; import { useForm } from "react-hook-form"; import { useProfile } from "../../contexts/ProfileContext"; -import { bioValidator, nameValidator } from "../../utils/validators"; -import { USE_PROFILE_ERROR_MESSAGE } from "../../utils/constants"; +import { bioValidator, nameValidator, profilePictureValidator } from "../../utils/validators"; +import { FAILED_PROFILE_UPDATE_MESSAGE, PROFILE_PIC_MAX_SIZE_ERROR_MESSAGE, USE_PROFILE_ERROR_MESSAGE } from "../../utils/constants"; +import { useRef, useState } from "react"; +import { Restore } from "@mui/icons-material"; +import { toast } from "react-toastify"; interface EditProfileModalProps { onClose: () => void; open: boolean; + currProfilePictureUrl?: string; currFirstName: string; currLastName: string; currBiography?: string; @@ -26,18 +34,24 @@ const StyledForm = styled("form")(({ theme }) => ({ })); const EditProfileModal: React.FC = (props) => { - const { open, onClose, currFirstName, currLastName, currBiography } = props; + const { open, onClose, currProfilePictureUrl, currFirstName, currLastName, currBiography } = props; const { register, formState: { errors, isValid, isDirty }, handleSubmit, + setValue, + getFieldState, } = useForm<{ + profilePic: File | null; + profilePictureUrl: string | null; firstName: string; lastName: string; biography: string; }>({ defaultValues: { + profilePic: null, + profilePictureUrl: currProfilePictureUrl || null, firstName: currFirstName, lastName: currLastName, biography: currBiography, @@ -51,7 +65,52 @@ const EditProfileModal: React.FC = (props) => { throw new Error(USE_PROFILE_ERROR_MESSAGE); } - const { updateProfile } = profile; + const { uploadProfilePicture, updateProfile } = profile; + + // profile pic functionality referenced and adapted from https://dreamix.eu/insights/uploading-files-with-react-hook-form/ + const [picPreview, setPicPreview] = useState(currProfilePictureUrl || null); + const hiddenFileInputRef = useRef(null); + const { ref: registerRef, ...rest } = register("profilePic", { validate: profilePictureValidator }); + const onClickUpload = () => { + hiddenFileInputRef.current?.click(); + } + const handleImageChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + setPicPreview(URL.createObjectURL(file)); + setValue("profilePic", file, { shouldValidate: true, shouldDirty: true }); + + if (currProfilePictureUrl) { + setValue("profilePictureUrl", null, { shouldDirty: true }); + } + } + } + + const onClickReset = () => { + if (getFieldState("profilePic").isDirty) { + setValue("profilePic", null, { shouldValidate: true, shouldDirty: true }); + if (hiddenFileInputRef.current) { + hiddenFileInputRef.current.value = ''; + } + } + if (getFieldState("profilePictureUrl").isDirty) { + setValue("profilePictureUrl", currProfilePictureUrl || null, { shouldDirty: true }) + } + setPicPreview(currProfilePictureUrl || null); + } + + const onClickDelete = () => { + if (getFieldState("profilePic").isDirty) { + setValue("profilePic", null, { shouldValidate: true, shouldDirty: true }); + if (hiddenFileInputRef.current) { + hiddenFileInputRef.current.value = ''; + } + } + if (currProfilePictureUrl) { + setValue("profilePictureUrl", null, { shouldDirty: true }); + } + setPicPreview(null); + } return (

@@ -62,10 +121,85 @@ const EditProfileModal: React.FC = (props) => { { - updateProfile(data); - onClose(); + if (data.profilePic) { + //send to firebase and get the url back + uploadProfilePicture(data.profilePic).then((res) => { + if (res) { + const url_data = { + firstName: data.firstName, + lastName: data.lastName, + biography: data.biography, + profilePictureUrl: res.imageUrl, + }; + updateProfile(url_data); + onClose(); + } else { + toast.error(FAILED_PROFILE_UPDATE_MESSAGE); + } + }); + } else { + const url_data = { + firstName: data.firstName, + lastName: data.lastName, + biography: data.biography, + profilePictureUrl: data.profilePictureUrl, + }; + updateProfile(url_data); + onClose(); + } })} > + ({ marginBottom: theme.spacing(2) })}> + {!picPreview + ? + : + } + {/* input referenced from https://dreamix.eu/insights/uploading-files-with-react-hook-form/ */} + { + registerRef(e); + hiddenFileInputRef.current = e; + }} + onChange={handleImageChange} + /> + + + + + + + + + {!!errors.profilePic + ? + {errors.profilePic.message} + + : + {PROFILE_PIC_MAX_SIZE_ERROR_MESSAGE} + } = (props) => { <> - + = (props) => { - const { firstName, lastName, username, biography } = props; + const { profilePictureUrl, firstName, lastName, username, biography } = props; return ( @@ -23,7 +24,9 @@ const ProfileDetails: React.FC = (props) => { marginBottom: theme.spacing(2), })} > - + ({ marginLeft: theme.spacing(2) })}> {firstName} {lastName} diff --git a/frontend/src/contexts/ProfileContext.tsx b/frontend/src/contexts/ProfileContext.tsx index 0866b503da..c9148d62df 100644 --- a/frontend/src/contexts/ProfileContext.tsx +++ b/frontend/src/contexts/ProfileContext.tsx @@ -12,6 +12,7 @@ interface UserProfileBase { firstName: string; lastName: string; biography?: string; + profilePictureUrl?: string | null; } interface UserProfile extends UserProfileBase { @@ -19,7 +20,6 @@ interface UserProfile extends UserProfileBase { username: string; email: string; isAdmin: boolean; - profilePictureUrl?: string; createdAt: string; } @@ -28,6 +28,7 @@ type ProfileContextType = { editProfileOpen: boolean; passwordModalOpen: boolean; fetchUser: (userId: string) => void; + uploadProfilePicture: (data: File) => Promise<{ message: string, imageUrl: string } | null>; updateProfile: (data: UserProfileBase) => void; updatePassword: ({ oldPassword, @@ -56,6 +57,24 @@ const ProfileContextProvider: React.FC<{ children: React.ReactNode }> = ({ .catch(() => setUser(null)); }; + const uploadProfilePicture = async ( + data: File + ): Promise<{ message: string, imageUrl: string } | null> => { + const formData = new FormData(); + formData.append("profilePic", data); + + try { + const res = await userClient.post("/users/images", formData, { + headers: { + "Content-Type": "multipart/form-data" + }, + }); + return res.data; + } catch { + return null; + } + } + const updateProfile = async (data: UserProfileBase) => { const token = localStorage.getItem("token"); await userClient @@ -101,6 +120,7 @@ const ProfileContextProvider: React.FC<{ children: React.ReactNode }> = ({ passwordModalOpen, editProfileOpen, fetchUser, + uploadProfilePicture, updateProfile, updatePassword, setEditProfileModalOpen, diff --git a/frontend/src/pages/Profile/index.tsx b/frontend/src/pages/Profile/index.tsx index fd83cf54b6..45f961ca69 100644 --- a/frontend/src/pages/Profile/index.tsx +++ b/frontend/src/pages/Profile/index.tsx @@ -67,6 +67,7 @@ const ProfilePage: React.FC = () => { ({ flex: 1, paddingRight: theme.spacing(4) })}> { setEditProfileModalOpen(false)} + currProfilePictureUrl={user.profilePictureUrl} currFirstName={user.firstName} currLastName={user.lastName} currBiography={user.biography} diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 7f7b65bb13..74424596a7 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -29,6 +29,10 @@ export const EMAIL_INVALID_ERROR_MESSAGE = "Email is invalid"; export const BIO_MAX_LENGTH_ERROR_MESSAGE = "Biography must be at most 255 characters long"; +/* Profile Picture Validation */ +export const PROFILE_PIC_MAX_SIZE_ERROR_MESSAGE = + "*Profile picture file size should be no more than 5MB"; + /* Password Validation */ export const PASSWORD_REQUIRED_ERROR_MESSAGE = "Password is required"; export const PASSWORD_MIN_LENGTH_ERROR_MESSAGE = diff --git a/frontend/src/utils/validators.ts b/frontend/src/utils/validators.ts index c4d403ef49..bec4fdab48 100644 --- a/frontend/src/utils/validators.ts +++ b/frontend/src/utils/validators.ts @@ -13,6 +13,7 @@ import { PASSWORD_SPECIAL_CHAR_ERROR_MESSAGE, PASSWORD_UPPER_CASE_ERROR_MESSAGE, PASSWORD_WEAK_ERROR_MESSAGE, + PROFILE_PIC_MAX_SIZE_ERROR_MESSAGE, USERNAME_ALLOWED_CHAR_ERROR_MESSAGE, USERNAME_LENGTH_ERROR_MESSAGE, } from "./constants"; @@ -65,6 +66,14 @@ export const bioValidator = (value: string) => { return true; }; +export const profilePictureValidator = (value: File | null) => { + if (value && value.size > 5 * 1024 * 1024) { + return PROFILE_PIC_MAX_SIZE_ERROR_MESSAGE; + } + + return true; +}; + const minLengthValidator = (value: string) => { return value.length >= 8; }; From fcfc613b1166a7d33bd42617fb899d7c70d66863 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Sun, 6 Oct 2024 10:26:06 +0800 Subject: [PATCH 70/78] Fix test --- docker-compose.yml | 1 + .../src/components/Navbar/Navbar.test.tsx | 54 +++++++++++++++---- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c8cb87b454..fb0cfc7f34 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: networks: - peerprep-network restart: on-failure + frontend: image: peerprep/frontend build: ./frontend diff --git a/frontend/src/components/Navbar/Navbar.test.tsx b/frontend/src/components/Navbar/Navbar.test.tsx index 000fa048e9..247459787f 100644 --- a/frontend/src/components/Navbar/Navbar.test.tsx +++ b/frontend/src/components/Navbar/Navbar.test.tsx @@ -4,6 +4,7 @@ import axios from "axios"; import { faker } from "@faker-js/faker"; import * as hooks from "../../contexts/AuthContext"; import Navbar from "."; +import { MemoryRouter } from "react-router-dom"; jest.mock("axios"); @@ -45,6 +46,7 @@ describe("Navigation routes", () => { signup: jest.fn(), login: jest.fn(), logout: jest.fn(), + loading: false, user: { id: "1", username, @@ -57,7 +59,11 @@ describe("Navigation routes", () => { isAdmin, }, })); - render(); + render( + + + + ); expect(screen.getByRole("link", { name: "Questions" })).toBeInTheDocument(); }); }); @@ -69,9 +75,14 @@ describe("Unauthenticated user", () => { signup: jest.fn(), login: jest.fn(), logout: jest.fn(), + loading: false, user: null, })); - render(); + render( + + + + ); expect(screen.getByRole("button", { name: "Sign up" })).toBeInTheDocument(); }); @@ -81,9 +92,14 @@ describe("Unauthenticated user", () => { signup: jest.fn(), login: jest.fn(), logout: jest.fn(), + loading: false, user: null, })); - render(); + render( + + + + ); expect(screen.getByRole("button", { name: "Log in" })).toBeInTheDocument(); }); }); @@ -117,6 +133,7 @@ describe("Authenticated user", () => { signup: jest.fn(), login: jest.fn(), logout: jest.fn(), + loading: false, user: { id: "1", username, @@ -129,7 +146,11 @@ describe("Authenticated user", () => { isAdmin, }, })); - render(); + render( + + + + ); expect(screen.getByTestId("profile")).toBeInTheDocument(); }); @@ -161,6 +182,7 @@ describe("Authenticated user", () => { signup: jest.fn(), login: jest.fn(), logout: jest.fn(), + loading: false, user: { id: "1", username, @@ -173,11 +195,15 @@ describe("Authenticated user", () => { isAdmin, }, })); - render(); + render( + + + + ); const avatar = screen.getByTestId("profile"); fireEvent.click(avatar); expect( - screen.getByRole("menuitem", { name: "Profile" }), + screen.getByRole("menuitem", { name: "Profile" }) ).toBeInTheDocument(); }); @@ -209,6 +235,7 @@ describe("Authenticated user", () => { signup: jest.fn(), login: jest.fn(), logout: jest.fn(), + loading: false, user: { id: "1", username, @@ -221,7 +248,11 @@ describe("Authenticated user", () => { isAdmin, }, })); - render(); + render( + + + + ); const avatar = screen.getByTestId("profile"); fireEvent.click(avatar); expect(mockUseNavigate).toHaveBeenCalled(); @@ -255,6 +286,7 @@ describe("Authenticated user", () => { signup: jest.fn(), login: jest.fn(), logout: jest.fn(), + loading: false, user: { id: "1", username, @@ -267,11 +299,15 @@ describe("Authenticated user", () => { isAdmin, }, })); - render(); + render( + + + + ); const avatar = screen.getByTestId("profile"); fireEvent.click(avatar); expect( - screen.getByRole("menuitem", { name: "Logout" }), + screen.getByRole("menuitem", { name: "Logout" }) ).toBeInTheDocument(); }); }); From fe5134e0593150e7b7dea49d6a8d2f87a52274b0 Mon Sep 17 00:00:00 2001 From: Guan Quan <76832850+guanquann@users.noreply.github.com> Date: Sun, 6 Oct 2024 10:53:22 +0800 Subject: [PATCH 71/78] Use cross-env instead of export for windows compatibility --- .husky/pre-commit | 7 ++++++ backend/question-service/config/db.ts | 2 +- backend/question-service/package-lock.json | 19 +++++++++++++++ backend/question-service/package.json | 5 ++-- backend/user-service/model/repository.ts | 2 +- backend/user-service/package-lock.json | 19 +++++++++++++++ backend/user-service/package.json | 5 ++-- package-lock.json | 27 ++++++++++++++++++++++ package.json | 8 +++++++ 9 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 .husky/pre-commit create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000000..4604c22e58 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,7 @@ +cd ./frontend && npm run lint && npm run test +cd .. + +cd ./backend/user-service && npm run lint && npm run test +cd ../.. + +cd ./backend/question-service && npm run lint && npm run test diff --git a/backend/question-service/config/db.ts b/backend/question-service/config/db.ts index f54e3758dd..410791ed92 100644 --- a/backend/question-service/config/db.ts +++ b/backend/question-service/config/db.ts @@ -5,7 +5,7 @@ dotenv.config(); const connectDB = async () => { try { - let mongoDBUri: string | undefined = + const mongoDBUri: string | undefined = process.env.NODE_ENV === "production" ? process.env.MONGO_CLOUD_URI : process.env.MONGO_LOCAL_URI; diff --git a/backend/question-service/package-lock.json b/backend/question-service/package-lock.json index 8bc358df51..9cdc5aacc2 100644 --- a/backend/question-service/package-lock.json +++ b/backend/question-service/package-lock.json @@ -33,6 +33,7 @@ "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.6", "@types/uuid": "^10.0.0", + "cross-env": "^7.0.3", "eslint": "^9.11.1", "globals": "^15.9.0", "jest": "^29.7.0", @@ -5350,6 +5351,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", diff --git a/backend/question-service/package.json b/backend/question-service/package.json index 8047683564..9bdfda8e2e 100644 --- a/backend/question-service/package.json +++ b/backend/question-service/package.json @@ -7,8 +7,8 @@ "seed": "tsx src/scripts/seed.ts", "start": "tsx server.ts", "dev": "tsx watch server.ts", - "test": "export NODE_ENV=test && jest", - "test:watch": "export NODE_ENV=test && jest --watch", + "test": "cross-env NODE_ENV=test && jest", + "test:watch": "cross-env NODE_ENV=test && jest --watch", "lint": "eslint ." }, "author": "", @@ -39,6 +39,7 @@ "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.6", "@types/uuid": "^10.0.0", + "cross-env": "^7.0.3", "eslint": "^9.11.1", "globals": "^15.9.0", "jest": "^29.7.0", diff --git a/backend/user-service/model/repository.ts b/backend/user-service/model/repository.ts index c7c7396c76..d2387b7871 100644 --- a/backend/user-service/model/repository.ts +++ b/backend/user-service/model/repository.ts @@ -3,7 +3,7 @@ import "dotenv/config"; import { connect } from "mongoose"; export async function connectToDB() { - let mongoDBUri: string | undefined = + const mongoDBUri: string | undefined = process.env.NODE_ENV === "production" ? process.env.MONGO_CLOUD_URI : process.env.MONGO_LOCAL_URI; diff --git a/backend/user-service/package-lock.json b/backend/user-service/package-lock.json index aa8cb73577..1fb413aa3e 100644 --- a/backend/user-service/package-lock.json +++ b/backend/user-service/package-lock.json @@ -31,6 +31,7 @@ "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.6", "@types/validator": "^13.12.2", + "cross-env": "^7.0.3", "eslint": "^9.11.1", "globals": "^15.9.0", "jest": "^29.7.0", @@ -3437,6 +3438,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", diff --git a/backend/user-service/package.json b/backend/user-service/package.json index 1227250ad5..c1956a87cf 100644 --- a/backend/user-service/package.json +++ b/backend/user-service/package.json @@ -8,8 +8,8 @@ "start": "tsx server.ts", "dev": "tsx watch server.ts", "lint": "eslint .", - "test": "export NODE_ENV=test && jest", - "test:watch": "export NODE_ENV=test && jest --watch" + "test": "cross-env NODE_ENV=test && jest", + "test:watch": "cross-env NODE_ENV=test && jest --watch" }, "keywords": [], "author": "", @@ -25,6 +25,7 @@ "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.6", "@types/validator": "^13.12.2", + "cross-env": "^7.0.3", "eslint": "^9.11.1", "globals": "^15.9.0", "jest": "^29.7.0", diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..d454d7eac6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "cs3219-ay2425s1-project-g28", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "husky": "^9.1.6" + } + }, + "node_modules/husky": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.6.tgz", + "integrity": "sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A==", + "dev": true, + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..df5486e833 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "devDependencies": { + "husky": "^9.1.6" + }, + "scripts": { + "prepare": "husky" + } +} From cb33897ee1e22fc66bca96a319e6555607770363 Mon Sep 17 00:00:00 2001 From: feliciagan <85786249+feliciagan@users.noreply.github.com> Date: Sun, 6 Oct 2024 13:42:05 +0800 Subject: [PATCH 72/78] update navbar avatar when edit profile -use auth context in edit profile modal to set user after update --- .../src/components/EditProfileModal/index.tsx | 62 +++++++++++++++---- frontend/src/contexts/AuthContext.tsx | 7 ++- frontend/src/contexts/ProfileContext.tsx | 35 +++++++---- 3 files changed, 77 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/EditProfileModal/index.tsx b/frontend/src/components/EditProfileModal/index.tsx index fb66aa8f5f..8ed9122554 100644 --- a/frontend/src/components/EditProfileModal/index.tsx +++ b/frontend/src/components/EditProfileModal/index.tsx @@ -15,10 +15,11 @@ import DeleteIcon from '@mui/icons-material/Delete'; import { useForm } from "react-hook-form"; import { useProfile } from "../../contexts/ProfileContext"; import { bioValidator, nameValidator, profilePictureValidator } from "../../utils/validators"; -import { FAILED_PROFILE_UPDATE_MESSAGE, PROFILE_PIC_MAX_SIZE_ERROR_MESSAGE, USE_PROFILE_ERROR_MESSAGE } from "../../utils/constants"; +import { FAILED_PROFILE_UPDATE_MESSAGE, PROFILE_PIC_MAX_SIZE_ERROR_MESSAGE, USE_AUTH_ERROR_MESSAGE, USE_PROFILE_ERROR_MESSAGE } from "../../utils/constants"; import { useRef, useState } from "react"; import { Restore } from "@mui/icons-material"; import { toast } from "react-toastify"; +import { useAuth } from "../../contexts/AuthContext"; interface EditProfileModalProps { onClose: () => void; @@ -44,17 +45,17 @@ const EditProfileModal: React.FC = (props) => { getFieldState, } = useForm<{ profilePic: File | null; - profilePictureUrl: string | null; + profilePictureUrl: string; firstName: string; lastName: string; biography: string; }>({ defaultValues: { profilePic: null, - profilePictureUrl: currProfilePictureUrl || null, + profilePictureUrl: currProfilePictureUrl || "", firstName: currFirstName, lastName: currLastName, - biography: currBiography, + biography: currBiography || "", }, mode: "all", }); @@ -65,7 +66,15 @@ const EditProfileModal: React.FC = (props) => { throw new Error(USE_PROFILE_ERROR_MESSAGE); } - const { uploadProfilePicture, updateProfile } = profile; + const { user, uploadProfilePicture, updateProfile } = profile; + + const auth = useAuth(); + + if (!auth) { + throw new Error(USE_AUTH_ERROR_MESSAGE); + } + + const { setUser } = auth; // profile pic functionality referenced and adapted from https://dreamix.eu/insights/uploading-files-with-react-hook-form/ const [picPreview, setPicPreview] = useState(currProfilePictureUrl || null); @@ -81,7 +90,7 @@ const EditProfileModal: React.FC = (props) => { setValue("profilePic", file, { shouldValidate: true, shouldDirty: true }); if (currProfilePictureUrl) { - setValue("profilePictureUrl", null, { shouldDirty: true }); + setValue("profilePictureUrl", "", { shouldDirty: true }); } } } @@ -94,9 +103,9 @@ const EditProfileModal: React.FC = (props) => { } } if (getFieldState("profilePictureUrl").isDirty) { - setValue("profilePictureUrl", currProfilePictureUrl || null, { shouldDirty: true }) + setValue("profilePictureUrl", currProfilePictureUrl || "", { shouldDirty: true }) } - setPicPreview(currProfilePictureUrl || null); + setPicPreview(currProfilePictureUrl || ""); } const onClickDelete = () => { @@ -107,7 +116,7 @@ const EditProfileModal: React.FC = (props) => { } } if (currProfilePictureUrl) { - setValue("profilePictureUrl", null, { shouldDirty: true }); + setValue("profilePictureUrl", "", { shouldDirty: true }); } setPicPreview(null); } @@ -122,7 +131,6 @@ const EditProfileModal: React.FC = (props) => { { if (data.profilePic) { - //send to firebase and get the url back uploadProfilePicture(data.profilePic).then((res) => { if (res) { const url_data = { @@ -131,7 +139,22 @@ const EditProfileModal: React.FC = (props) => { biography: data.biography, profilePictureUrl: res.imageUrl, }; - updateProfile(url_data); + updateProfile(url_data).then((res) => { + if (res && user) { + const updatedUser = { + id: user.id, + username: user.username, + firstName: url_data.firstName, + lastName: url_data.lastName, + email: user.email, + biography: url_data.biography, + profilePictureUrl: url_data.profilePictureUrl, + createdAt: user.createdAt, + isAdmin: user.isAdmin, + } + setUser(updatedUser); + } + }); onClose(); } else { toast.error(FAILED_PROFILE_UPDATE_MESSAGE); @@ -144,7 +167,22 @@ const EditProfileModal: React.FC = (props) => { biography: data.biography, profilePictureUrl: data.profilePictureUrl, }; - updateProfile(url_data); + updateProfile(url_data).then((res) => { + if (res && user) { + const updatedUser = { + id: user.id, + username: user.username, + firstName: url_data.firstName, + lastName: url_data.lastName, + email: user.email, + biography: url_data.biography, + profilePictureUrl: url_data.profilePictureUrl, + createdAt: user.createdAt, + isAdmin: user.isAdmin, + } + setUser(updatedUser); + } + }); onClose(); } })} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index f0d60a3899..feb1e1d3a6 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -13,8 +13,8 @@ type User = { firstName: string; lastName: string; email: string; - biography: string; - profilePictureUrl: string; + biography?: string; + profilePictureUrl?: string; createdAt: string; isAdmin: boolean; }; @@ -30,6 +30,7 @@ type AuthContextType = { login: (email: string, password: string) => void; logout: () => void; user: User | null; + setUser: (data: User) => void; }; const AuthContext = createContext(null); @@ -110,7 +111,7 @@ const AuthProvider: React.FC<{ children?: React.ReactNode }> = (props) => { } return ( - + {children} ); diff --git a/frontend/src/contexts/ProfileContext.tsx b/frontend/src/contexts/ProfileContext.tsx index c9148d62df..a0ea1b3cbd 100644 --- a/frontend/src/contexts/ProfileContext.tsx +++ b/frontend/src/contexts/ProfileContext.tsx @@ -7,12 +7,13 @@ import { SUCCESS_PW_UPDATE_MESSAGE, } from "../utils/constants"; import { toast } from "react-toastify"; +import axios from "axios"; interface UserProfileBase { firstName: string; lastName: string; biography?: string; - profilePictureUrl?: string | null; + profilePictureUrl?: string; } interface UserProfile extends UserProfileBase { @@ -29,7 +30,7 @@ type ProfileContextType = { passwordModalOpen: boolean; fetchUser: (userId: string) => void; uploadProfilePicture: (data: File) => Promise<{ message: string, imageUrl: string } | null>; - updateProfile: (data: UserProfileBase) => void; + updateProfile: (data: UserProfileBase) => Promise; updatePassword: ({ oldPassword, newPassword, @@ -75,21 +76,31 @@ const ProfileContextProvider: React.FC<{ children: React.ReactNode }> = ({ } } - const updateProfile = async (data: UserProfileBase) => { + const updateProfile = async ( + data: UserProfileBase + ): Promise => { const token = localStorage.getItem("token"); - await userClient - .patch(`/users/${user?.id}`, data, { + try { + const res = await userClient + .patch(`/users/${user?.id}`, data, + { headers: { Authorization: `Bearer ${token}` }, }) - .then((res) => { - setUser(res.data.data); - toast.success(SUCCESS_PROFILE_UPDATE_MESSAGE); - }) - .catch((err) => { + setUser(res.data.data); + toast.success(SUCCESS_PROFILE_UPDATE_MESSAGE); + return true; + } catch (error) { + console.error('Error:', error); + if(axios.isAxiosError(error)) { const message = - err.response?.data.message || FAILED_PROFILE_UPDATE_MESSAGE; + error.response?.data.message || FAILED_PROFILE_UPDATE_MESSAGE; toast.error(message); - }); + return false; + } else { + toast.error(FAILED_PROFILE_UPDATE_MESSAGE); + return false; + } + } }; const updatePassword = async ({ From 5e80ee00df979aded98a16de025ba55003b79b81 Mon Sep 17 00:00:00 2001 From: feliciagan <85786249+feliciagan@users.noreply.github.com> Date: Sun, 6 Oct 2024 17:34:37 +0800 Subject: [PATCH 73/78] resolve comment issue --- frontend/src/contexts/AuthContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index de81b1eef3..73361a058c 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -30,7 +30,7 @@ type AuthContextType = { login: (email: string, password: string) => void; logout: () => void; user: User | null; - setUser: (data: User) => void; + setUser: React.Dispatch>; loading: boolean; }; From 57b1da952a732843890c41ef1fe5a9d74ce57af2 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Sun, 6 Oct 2024 19:53:14 +0800 Subject: [PATCH 74/78] Update readme --- README.md | 21 +++++++++++++++++---- backend/README.md | 16 +++++++++------- backend/question-service/.env.sample | 2 +- backend/question-service/README.md | 10 +++++----- backend/question-service/config/db.ts | 1 - backend/question-service/server.ts | 6 ++++-- backend/user-service/.dockerignore | 1 + backend/user-service/README.md | 8 +++++--- frontend/.dockerignore | 2 ++ frontend/src/contexts/AuthContext.tsx | 2 +- 10 files changed, 45 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index d61c993554..29bbafc992 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,28 @@ We will be using Docker to set up PeerPrep. Install Docker [here](https://docs.docker.com/get-started/get-docker). -Follow the instructions in the [backend directory](./backend/) first before proceeding. +Follow the instructions in [here](./backend/README.md) first before proceeding. + +1. Build all the services (without using cache). + +``` +docker-compose build --no-cache +``` + +2. Run all the services (in detached mode). + +``` +docker-compose up -d +``` + +To stop all the services, use the following command: -Run: ``` -docker-compose up --build +docker-compose down ``` ## Useful links - User Service: http://localhost:3001 - Question Service: http://localhost:3000 -- Frontend: http://localhost:5173 \ No newline at end of file +- Frontend: http://localhost:5173 diff --git a/backend/README.md b/backend/README.md index 134346f3fe..192db72aa9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -10,7 +10,7 @@ 3. Your local Mongo URI will be `mongodb://:@mongo:27017/`. Take note of it as we will be using in the `.env` file in the various microservices later on. -5. You can view the MongoDB collections locally using Mongo Express. To set up Mongo Express, update `ME_CONFIG_BASICAUTH_USERNAME` and `ME_CONFIG_BASICAUTH_PASSWORD`. The username and password will be the login credentials when you access Mongo Express at http://localhost:8081. +4. You can view the MongoDB collections locally using Mongo Express. To set up Mongo Express, update `ME_CONFIG_BASICAUTH_USERNAME` and `ME_CONFIG_BASICAUTH_PASSWORD`. The username and password will be the login credentials when you access Mongo Express at http://localhost:8081. ## Setting-up cloud MongoDB (in production) @@ -49,7 +49,6 @@ ![alt text](./GuideAssets/Selection4.png) - ![alt text](./GuideAssets/Security.png) 7. Next, click on `Add my Current IP Address`. This will whitelist your IP address and allow you to connect to the MongoDB Database. @@ -61,23 +60,26 @@ 9. [Optional] Whitelisting All IP's 1. Select `Network Access` from the left side pane on Dashboard. - ![alt text](./GuideAssets/SidePane.png) + + ![alt text](./GuideAssets/SidePane.png) 2. Click on the `Add IP Address` Button - ![alt text](./GuideAssets/AddIPAddress.png) + + ![alt text](./GuideAssets/AddIPAddress.png) 3. Select the `ALLOW ACCESS FROM ANYWHERE` Button and Click `Confirm` - ![alt text](./GuideAssets/IPWhitelisting.png) + + ![alt text](./GuideAssets/IPWhitelisting.png) 4. Now, any IP Address can access this Database. 10. After setting up, go to the Database Deployment Page. You would see a list of the Databases you have set up. Select `Connect` on the cluster you just created earlier. - ![alt text](GuideAssets/ConnectCluster.png) + ![alt text](GuideAssets/ConnectCluster.png) 11. Select the `Drivers` option. - ![alt text](GuideAssets/DriverSelection.png) + ![alt text](GuideAssets/DriverSelection.png) 12. Select `Node.js` in the `Driver` pull-down menu, and copy the connection string. diff --git a/backend/question-service/.env.sample b/backend/question-service/.env.sample index 2108051616..fb1103500f 100644 --- a/backend/question-service/.env.sample +++ b/backend/question-service/.env.sample @@ -1,7 +1,7 @@ NODE_ENV=development MONGO_CLOUD_URI= -MONGO_LOCAL_URI=mongodb://root:example@mongo:27017/ +MONGO_LOCAL_URI= FIREBASE_PROJECT_ID= FIREBASE_PRIVATE_KEY= diff --git a/backend/question-service/README.md b/backend/question-service/README.md index c5e09ae826..be53e8b2d7 100644 --- a/backend/question-service/README.md +++ b/backend/question-service/README.md @@ -1,6 +1,6 @@ # Question Service -> If you have not set-up either a local or cloud MongoDB, go [here](../) first before proceding. +> If you have not set-up either a local or cloud MongoDB, go [here](../README.md) first before proceding. ## Setting-up Firebase @@ -32,7 +32,9 @@ 1. In the `question-service` directory, create a copy of the `.env.sample` file and name it `.env`. -2. Update `MONGO_CLOUD_URI`, `MONGO_LOCAL_URI`, `FIREBASE_PROJECT_ID`, `FIREBASE_PRIVATE_KEY`, `FIREBASE_CLIENT_EMAIL`, `FIREBASE_STORAGE_BUCKET`. +2. To connect to your cloud MongoDB instead of your local MongoDB, set the `NODE_ENV` to `production` instead of `development`. + +3. Update `MONGO_CLOUD_URI`, `MONGO_LOCAL_URI`, `FIREBASE_PROJECT_ID`, `FIREBASE_PRIVATE_KEY`, `FIREBASE_CLIENT_EMAIL`, `FIREBASE_STORAGE_BUCKET`. - `FIREBASE_PROJECT_ID` is the value of `project_id` found in the downloaded json file. - `FIREBASE_PRIVATE_KEY` is the value of `private_key` found in the downloaded json file. - `FIREBASE_CLIENT_EMAIL` is the value of `client_email` found in the downloaded json file. @@ -53,10 +55,8 @@ 1. With Docker - Run `docker ps` to get a list of the Docker containers on your machine. - - Retrieve the `CONTAINER_ID` of `peerprep/question-service`. - - - Run `docker exec -it npm run seed`. + - Run `docker exec -it npm run seed`. 2. Without Docker diff --git a/backend/question-service/config/db.ts b/backend/question-service/config/db.ts index 410791ed92..e5918d0739 100644 --- a/backend/question-service/config/db.ts +++ b/backend/question-service/config/db.ts @@ -15,7 +15,6 @@ const connectDB = async () => { } await mongoose.connect(mongoDBUri); - console.log("MongoDB connected"); } catch (error) { console.error(error); process.exit(1); diff --git a/backend/question-service/server.ts b/backend/question-service/server.ts index af74cdeeb0..6e2700160a 100644 --- a/backend/question-service/server.ts +++ b/backend/question-service/server.ts @@ -6,10 +6,12 @@ const PORT = process.env.PORT || 3000; if (process.env.NODE_ENV !== "test") { connectDB() .then(() => { - console.log("MongoDB connected"); + console.log("MongoDB Connected!"); app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); + console.log( + `Question service server listening on http://localhost:${PORT}`, + ); }); }) .catch((err) => { diff --git a/backend/user-service/.dockerignore b/backend/user-service/.dockerignore index 7ee74d25c3..4abc77f632 100644 --- a/backend/user-service/.dockerignore +++ b/backend/user-service/.dockerignore @@ -1,3 +1,4 @@ +coverage node_modules tests .env* diff --git a/backend/user-service/README.md b/backend/user-service/README.md index e7303312ab..f141d11c96 100644 --- a/backend/user-service/README.md +++ b/backend/user-service/README.md @@ -1,14 +1,16 @@ # User Service Guide -> If you have not set-up either a local or cloud MongoDB, as well as Firebase, go [here](../) first before proceding. +> If you have not set-up either a local or cloud MongoDB, as well as Firebase, go [here](../README.md) first before proceding. ## Setting-up 1. In the `user-service` directory, create a copy of the `.env.sample` file and name it `.env`. -2. Update `MONGO_CLOUD_URI`, `MONGO_LOCAL_URI`, `FIREBASE_PROJECT_ID`, `FIREBASE_PRIVATE_KEY`, `FIREBASE_CLIENT_EMAIL`, `FIREBASE_STORAGE_BUCKET`, `JWT_SECRET`. +2. To connect to your cloud MongoDB instead of your local MongoDB, set the `NODE_ENV` to `production` instead of `development`. -3. A default admin account (`email: admin@gmail.com` and `password: Admin@123`) wil be created. If you wish to change the default credentials, update them in `.env`. Alternatively, you can also edit your credentials and user profile after you have created the default account. +3. Update `MONGO_CLOUD_URI`, `MONGO_LOCAL_URI`, `FIREBASE_PROJECT_ID`, `FIREBASE_PRIVATE_KEY`, `FIREBASE_CLIENT_EMAIL`, `FIREBASE_STORAGE_BUCKET`, `JWT_SECRET`. + +4. A default admin account (`email: admin@gmail.com` and `password: Admin@123`) wil be created. If you wish to change the default credentials, update them in `.env`. Alternatively, you can also edit your credentials and user profile after you have created the default account. ## Running User Service without Docker diff --git a/frontend/.dockerignore b/frontend/.dockerignore index 3c3629e647..9b07ce379e 100644 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -1 +1,3 @@ +coverage node_modules +*.md diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 73361a058c..7be6dae74c 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -56,7 +56,7 @@ const AuthProvider: React.FC<{ children?: React.ReactNode }> = (props) => { }); } else { setUser(null); - setLoading(false); + setTimeout(() => setLoading(false), 500); } }, []); From b5f084a72a7156915cd4757cd1bbf33ceb70c84e Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Sun, 6 Oct 2024 20:52:05 +0800 Subject: [PATCH 75/78] Shift firebase set up to backend directory readme --- backend/README.md | 42 +++++++++++++++++++++++++++--- backend/question-service/README.md | 32 +---------------------- backend/user-service/README.md | 4 +-- 3 files changed, 42 insertions(+), 36 deletions(-) diff --git a/backend/README.md b/backend/README.md index 192db72aa9..70297244f3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,10 @@ # PeerPrep Backend -> Set-up either a local or cloud MongoDB first, before proceeding to each microservice for more instructions. +> Before proceeding to each microservice for more instructions: + +1. Set-up either a local or cloud MongoDB. + +2. Set-up Firebase. ## Setting-up local MongoDB (only if you are using Docker) @@ -8,7 +12,7 @@ 2. To set up credentials for the MongoDB database, update `MONGO_INITDB_ROOT_USERNAME`, `MONGO_INITDB_ROOT_PASSWORD` of the `.env` file. -3. Your local Mongo URI will be `mongodb://:@mongo:27017/`. Take note of it as we will be using in the `.env` file in the various microservices later on. +3. Your local Mongo URI will be `mongodb://:@mongo:27017/`. Take note of it as we will be using in the `.env` files in the various microservices later on. 4. You can view the MongoDB collections locally using Mongo Express. To set up Mongo Express, update `ME_CONFIG_BASICAUTH_USERNAME` and `ME_CONFIG_BASICAUTH_PASSWORD`. The username and password will be the login credentials when you access Mongo Express at http://localhost:8081. @@ -87,4 +91,36 @@ ![alt text](GuideAssets/ConnectionString.png) -13. Your cloud Mongo URI will be the string you copied earlier. Take note of it as we will be using in the `.env` file in the various microservices later on. +13. Your cloud Mongo URI will be the string you copied earlier. Take note of it as we will be using in the `.env` files in the various microservices later on. + +## Setting-up Firebase + +1. Go to https://console.firebase.google.com/u/0/. + +2. Create a project and choose a project name. Navigate to `Storage` and click on it to activate it. + +3. Select `Start in production mode` and your preferred cloud storage region. + +4. After Storage is created, go to `Rules` section and set rule to: + + ``` + rules_version = '2'; + service firebase.storage { + match /b/{bucket}/o { + match /{allPaths=**} { + allow read: if true; + allow write: if request.auth != null; + } + } + } + ``` + + This rule ensures that only verified users can upload images while ensuring that URLs of images are public. Remember to click `Publish` to save changes. + +5. Go to `Settings`, `Project settings`, `Service accounts` and click `Generate new private key`. This will download a `.json` file, which will contain your credentials. + +6. You will need to update the following variables in the `.env` files of the various microservices later on. + - `FIREBASE_PROJECT_ID` is the value of `project_id` found in the downloaded json file. + - `FIREBASE_PRIVATE_KEY` is the value of `private_key` found in the downloaded json file. + - `FIREBASE_CLIENT_EMAIL` is the value of `client_email` found in the downloaded json file. + - `FIREBASE_STORAGE_BUCKET` is the folder path of the Storage. It should look something like `gs://.appspot.com`. diff --git a/backend/question-service/README.md b/backend/question-service/README.md index be53e8b2d7..98ad1d18b8 100644 --- a/backend/question-service/README.md +++ b/backend/question-service/README.md @@ -1,32 +1,6 @@ # Question Service -> If you have not set-up either a local or cloud MongoDB, go [here](../README.md) first before proceding. - -## Setting-up Firebase - -1. Go to https://console.firebase.google.com/u/0/. - -2. Create a project and choose a project name. Navigate to `Storage` and click on it to activate it. - -3. Select `Start in production mode` and your preferred cloud storage region. - -4. After Storage is created, go to `Rules` section and set rule to: - - ``` - rules_version = '2'; - service firebase.storage { - match /b/{bucket}/o { - match /{allPaths=**} { - allow read: if true; - allow write: if request.auth != null; - } - } - } - ``` - - This rule ensures that only verified users can upload images while ensuring that URLs of images are public. Remember to click `Publish` to save changes. - -5. Go to `Settings`, `Project settings`, `Service accounts` and click `Generate new private key`. This will download a `.json` file, which will contain your credentials. +> If you have not set-up either a local or cloud MongoDB, as well as Firebase, visit [this](../README.md) before proceeding. ## Setting-up Question Service @@ -35,10 +9,6 @@ 2. To connect to your cloud MongoDB instead of your local MongoDB, set the `NODE_ENV` to `production` instead of `development`. 3. Update `MONGO_CLOUD_URI`, `MONGO_LOCAL_URI`, `FIREBASE_PROJECT_ID`, `FIREBASE_PRIVATE_KEY`, `FIREBASE_CLIENT_EMAIL`, `FIREBASE_STORAGE_BUCKET`. - - `FIREBASE_PROJECT_ID` is the value of `project_id` found in the downloaded json file. - - `FIREBASE_PRIVATE_KEY` is the value of `private_key` found in the downloaded json file. - - `FIREBASE_CLIENT_EMAIL` is the value of `client_email` found in the downloaded json file. - - `FIREBASE_STORAGE_BUCKET` is the folder path of the Storage. It should look something like `gs://.appspot.com`. ## Running Question Service without Docker diff --git a/backend/user-service/README.md b/backend/user-service/README.md index f141d11c96..3a098f22ab 100644 --- a/backend/user-service/README.md +++ b/backend/user-service/README.md @@ -1,8 +1,8 @@ # User Service Guide -> If you have not set-up either a local or cloud MongoDB, as well as Firebase, go [here](../README.md) first before proceding. +> If you have not set-up either a local or cloud MongoDB, as well as Firebase, visit [this](../README.md) before proceeding. -## Setting-up +## Setting-up User Service 1. In the `user-service` directory, create a copy of the `.env.sample` file and name it `.env`. From 62575ad3fe98c3b9479f0eb7b660bbff33a4af3d Mon Sep 17 00:00:00 2001 From: jolynloh Date: Sun, 6 Oct 2024 22:07:03 +0800 Subject: [PATCH 76/78] Remove indicators --- backend/question-service/tests/setup.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/backend/question-service/tests/setup.ts b/backend/question-service/tests/setup.ts index ee14d63d8e..455506c382 100644 --- a/backend/question-service/tests/setup.ts +++ b/backend/question-service/tests/setup.ts @@ -10,13 +10,8 @@ beforeAll(async () => { if (mongoose.connection.readyState !== 0) { await mongoose.disconnect(); } -<<<<<<< HEAD - mongoose.connect(mongoUri, {}); -======= - await mongoose.connect(mongoUri, {}); ->>>>>>> development }); afterEach(async () => { From f7917877e9442bc66b1e074d3e0998144190ed90 Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Sun, 6 Oct 2024 22:23:02 +0800 Subject: [PATCH 77/78] Fix linting issues --- .../src/components/EditProfileModal/index.tsx | 111 +++++++++++------- frontend/src/contexts/ProfileContext.tsx | 26 ++-- frontend/src/pages/Home/index.tsx | 3 +- frontend/src/pages/Profile/index.tsx | 11 +- 4 files changed, 88 insertions(+), 63 deletions(-) diff --git a/frontend/src/components/EditProfileModal/index.tsx b/frontend/src/components/EditProfileModal/index.tsx index 8ed9122554..6b51ce4e71 100644 --- a/frontend/src/components/EditProfileModal/index.tsx +++ b/frontend/src/components/EditProfileModal/index.tsx @@ -11,11 +11,20 @@ import { TextField, Typography, } from "@mui/material"; -import DeleteIcon from '@mui/icons-material/Delete'; +import DeleteIcon from "@mui/icons-material/Delete"; import { useForm } from "react-hook-form"; import { useProfile } from "../../contexts/ProfileContext"; -import { bioValidator, nameValidator, profilePictureValidator } from "../../utils/validators"; -import { FAILED_PROFILE_UPDATE_MESSAGE, PROFILE_PIC_MAX_SIZE_ERROR_MESSAGE, USE_AUTH_ERROR_MESSAGE, USE_PROFILE_ERROR_MESSAGE } from "../../utils/constants"; +import { + bioValidator, + nameValidator, + profilePictureValidator, +} from "../../utils/validators"; +import { + FAILED_PROFILE_UPDATE_MESSAGE, + PROFILE_PIC_MAX_SIZE_ERROR_MESSAGE, + USE_AUTH_ERROR_MESSAGE, + USE_PROFILE_ERROR_MESSAGE, +} from "../../utils/constants"; import { useRef, useState } from "react"; import { Restore } from "@mui/icons-material"; import { toast } from "react-toastify"; @@ -35,7 +44,14 @@ const StyledForm = styled("form")(({ theme }) => ({ })); const EditProfileModal: React.FC = (props) => { - const { open, onClose, currProfilePictureUrl, currFirstName, currLastName, currBiography } = props; + const { + open, + onClose, + currProfilePictureUrl, + currFirstName, + currLastName, + currBiography, + } = props; const { register, @@ -77,12 +93,16 @@ const EditProfileModal: React.FC = (props) => { const { setUser } = auth; // profile pic functionality referenced and adapted from https://dreamix.eu/insights/uploading-files-with-react-hook-form/ - const [picPreview, setPicPreview] = useState(currProfilePictureUrl || null); + const [picPreview, setPicPreview] = useState( + currProfilePictureUrl || null + ); const hiddenFileInputRef = useRef(null); - const { ref: registerRef, ...rest } = register("profilePic", { validate: profilePictureValidator }); + const { ref: registerRef, ...rest } = register("profilePic", { + validate: profilePictureValidator, + }); const onClickUpload = () => { hiddenFileInputRef.current?.click(); - } + }; const handleImageChange = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { @@ -93,33 +113,35 @@ const EditProfileModal: React.FC = (props) => { setValue("profilePictureUrl", "", { shouldDirty: true }); } } - } + }; const onClickReset = () => { if (getFieldState("profilePic").isDirty) { setValue("profilePic", null, { shouldValidate: true, shouldDirty: true }); if (hiddenFileInputRef.current) { - hiddenFileInputRef.current.value = ''; + hiddenFileInputRef.current.value = ""; } } if (getFieldState("profilePictureUrl").isDirty) { - setValue("profilePictureUrl", currProfilePictureUrl || "", { shouldDirty: true }) + setValue("profilePictureUrl", currProfilePictureUrl || "", { + shouldDirty: true, + }); } setPicPreview(currProfilePictureUrl || ""); - } + }; - const onClickDelete = () => { + const onClickDelete = () => { if (getFieldState("profilePic").isDirty) { setValue("profilePic", null, { shouldValidate: true, shouldDirty: true }); if (hiddenFileInputRef.current) { - hiddenFileInputRef.current.value = ''; + hiddenFileInputRef.current.value = ""; } } if (currProfilePictureUrl) { setValue("profilePictureUrl", "", { shouldDirty: true }); } setPicPreview(null); - } + }; return ( @@ -151,7 +173,7 @@ const EditProfileModal: React.FC = (props) => { profilePictureUrl: url_data.profilePictureUrl, createdAt: user.createdAt, isAdmin: user.isAdmin, - } + }; setUser(updatedUser); } }); @@ -179,7 +201,7 @@ const EditProfileModal: React.FC = (props) => { profilePictureUrl: url_data.profilePictureUrl, createdAt: user.createdAt, isAdmin: user.isAdmin, - } + }; setUser(updatedUser); } }); @@ -187,16 +209,18 @@ const EditProfileModal: React.FC = (props) => { } })} > - ({ marginBottom: theme.spacing(2) })}> - {!picPreview - ? - : - } + sx={(theme) => ({ marginBottom: theme.spacing(2) })} + > + {!picPreview ? ( + + ) : ( + + )} {/* input referenced from https://dreamix.eu/insights/uploading-files-with-react-hook-form/ */} = (props) => { }} onChange={handleImageChange} /> - - - + + - - + + - {!!errors.profilePic - ? - {errors.profilePic.message} - - : - {PROFILE_PIC_MAX_SIZE_ERROR_MESSAGE} - } + {errors.profilePic ? ( + + {errors.profilePic.message} + + ) : ( + + {PROFILE_PIC_MAX_SIZE_ERROR_MESSAGE} + + )} void; - uploadProfilePicture: (data: File) => Promise<{ message: string, imageUrl: string } | null>; + uploadProfilePicture: ( + data: File + ) => Promise<{ message: string; imageUrl: string } | null>; updateProfile: (data: UserProfileBase) => Promise; updatePassword: ({ oldPassword, @@ -60,38 +64,34 @@ const ProfileContextProvider: React.FC<{ children: React.ReactNode }> = ({ const uploadProfilePicture = async ( data: File - ): Promise<{ message: string, imageUrl: string } | null> => { + ): Promise<{ message: string; imageUrl: string } | null> => { const formData = new FormData(); formData.append("profilePic", data); try { const res = await userClient.post("/users/images", formData, { headers: { - "Content-Type": "multipart/form-data" + "Content-Type": "multipart/form-data", }, }); return res.data; } catch { return null; } - } + }; - const updateProfile = async ( - data: UserProfileBase - ): Promise => { + const updateProfile = async (data: UserProfileBase): Promise => { const token = localStorage.getItem("token"); try { - const res = await userClient - .patch(`/users/${user?.id}`, data, - { + const res = await userClient.patch(`/users/${user?.id}`, data, { headers: { Authorization: `Bearer ${token}` }, - }) + }); setUser(res.data.data); toast.success(SUCCESS_PROFILE_UPDATE_MESSAGE); return true; } catch (error) { - console.error('Error:', error); - if(axios.isAxiosError(error)) { + console.error("Error:", error); + if (axios.isAxiosError(error)) { const message = error.response?.data.message || FAILED_PROFILE_UPDATE_MESSAGE; toast.error(message); diff --git a/frontend/src/pages/Home/index.tsx b/frontend/src/pages/Home/index.tsx index 9167c1f05b..f916bc85f3 100644 --- a/frontend/src/pages/Home/index.tsx +++ b/frontend/src/pages/Home/index.tsx @@ -62,7 +62,8 @@ const Home: React.FC = () => { maxWidth: "80%", })} > - Specify your question preferences and sit back as we find you the best match. + Specify your question preferences and sit back as we find you the best + match. {/* { } fetchUser(userId); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); if (!user) { @@ -106,7 +107,7 @@ const ProfilePage: React.FC = () => { ({ flex: 3, paddingLeft: theme.spacing(4) })}> Questions attempted - {editProfileOpen && + {editProfileOpen && ( setEditProfileModalOpen(false)} @@ -114,12 +115,14 @@ const ProfilePage: React.FC = () => { currFirstName={user.firstName} currLastName={user.lastName} currBiography={user.biography} - />} - {passwordModalOpen && + /> + )} + {passwordModalOpen && ( setPasswordModalOpen(false)} - />} + /> + )} ); From e1b04e1c1d8462c9e0ec943922e35d877e091744 Mon Sep 17 00:00:00 2001 From: Nicole Lim Date: Sun, 6 Oct 2024 22:26:23 +0800 Subject: [PATCH 78/78] Exclude matching form --- frontend/src/pages/Home/index.tsx | 56 +++++++++++++++---------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/frontend/src/pages/Home/index.tsx b/frontend/src/pages/Home/index.tsx index f916bc85f3..9d4a8e6457 100644 --- a/frontend/src/pages/Home/index.tsx +++ b/frontend/src/pages/Home/index.tsx @@ -1,40 +1,40 @@ import { - Autocomplete, - Button, - Card, - FormControl, - Grid2, - TextField, + // Autocomplete, + // Button, + // Card, + // FormControl, + // Grid2, + // TextField, Typography, } from "@mui/material"; -import { useEffect, useReducer, useState } from "react"; +// import { useEffect, useReducer, useState } from "react"; import classes from "./index.module.css"; import AppMargin from "../../components/AppMargin"; -import { - complexityList, - languageList, - maxMatchTimeout, - minMatchTimeout, -} from "../../utils/constants"; -import reducer, { - getQuestionCategories, - initialState, -} from "../../reducers/questionReducer"; -import CustomChip from "../../components/CustomChip"; +// import { +// complexityList, +// languageList, +// maxMatchTimeout, +// minMatchTimeout, +// } from "../../utils/constants"; +// import reducer, { +// getQuestionCategories, +// initialState, +// } from "../../reducers/questionReducer"; +// import CustomChip from "../../components/CustomChip"; // import homepageImage from "/homepage_image.svg"; const Home: React.FC = () => { - const [complexity, setComplexity] = useState([]); - const [selectedCategories, setSelectedCategories] = useState([]); - const [language, setLanguage] = useState([]); - const [timeout, setTimeout] = useState(30); + // const [complexity, setComplexity] = useState([]); + // const [selectedCategories, setSelectedCategories] = useState([]); + // const [language, setLanguage] = useState([]); + // const [timeout, setTimeout] = useState(30); - const [state, dispatch] = useReducer(reducer, initialState); + // const [state, dispatch] = useReducer(reducer, initialState); - useEffect(() => { - getQuestionCategories(dispatch); - }, []); + // useEffect(() => { + // getQuestionCategories(dispatch); + // }, []); return ( { objectFit: "contain", }} /> */} - { > Find my match! - + */} ); };