diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2940f9a923..5dde70ed67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,12 +4,17 @@ on: push: branches: - "*" - pull_request: - branches: - - "*" 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 }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + +permissions: + contents: read jobs: ci: @@ -30,6 +35,16 @@ jobs: - name: Linting working-directory: ${{ matrix.service }} run: npm run lint - # - name: Tests - # working-directory: ${{ matrix.service }} - # run: npm test + - 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 + echo "FIREBASE_CLIENT_EMAIL=${{ env.FIREBASE_CLIENT_EMAIL }}" >> .env + echo "FIREBASE_STORAGE_BUCKET=${{ env.FIREBASE_STORAGE_BUCKET }}" >> .env + echo "JWT_SECRET=${{ env.JWT_SECRET }}" >> .env + - name: Tests + working-directory: ${{ matrix.service }} + run: npm test 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/README.md b/README.md index 38f70711d4..29bbafc992 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,31 @@ -[![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 28 -# CS3219 Project (PeerPrep) - AY2425S1 +## Setting up -## Group: G28 +We will be using Docker to set up PeerPrep. Install Docker [here](https://docs.docker.com/get-started/get-docker). -### Note: +Follow the instructions in [here](./backend/README.md) first before proceeding. -- 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. +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: + +``` +docker-compose down +``` + +## Useful links + +- User Service: http://localhost:3001 +- Question Service: http://localhost:3000 +- Frontend: http://localhost:5173 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..70297244f3 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,126 @@ +# PeerPrep Backend + +> 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) + +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` 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. + +## 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` 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/.dockerignore b/backend/question-service/.dockerignore new file mode 100644 index 0000000000..4abc77f632 --- /dev/null +++ b/backend/question-service/.dockerignore @@ -0,0 +1,5 @@ +coverage +node_modules +tests +.env* +*.md diff --git a/backend/question-service/.env.sample b/backend/question-service/.env.sample index abbae20e05..fb1103500f 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= + +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/Dockerfile b/backend/question-service/Dockerfile new file mode 100644 index 0000000000..0f8e144f33 --- /dev/null +++ b/backend/question-service/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /question-service + +COPY package*.json ./ + +RUN npm ci + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/backend/question-service/README.md b/backend/question-service/README.md index 4939cfbf0b..98ad1d18b8 100644 --- a/backend/question-service/README.md +++ b/backend/question-service/README.md @@ -1,71 +1,39 @@ # Question Service -## Setting-up MongoDB +> If you have not set-up either a local or cloud MongoDB, as well as Firebase, visit [this](../README.md) before proceeding. -> :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. +## Setting-up Question Service -1. Set up a MongoDB Shared Cluster by following the steps in this [Guide](../user-service/MongoDBSetup.md). +1. In the `question-service` directory, create a copy of the `.env.sample` file and name it `.env`. -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. +2. To connect to your cloud MongoDB instead of your local MongoDB, set the `NODE_ENV` to `production` instead of `development`. - ![alt text](../user-service/GuideAssets/ConnectCluster.png) +3. Update `MONGO_CLOUD_URI`, `MONGO_LOCAL_URI`, `FIREBASE_PROJECT_ID`, `FIREBASE_PRIVATE_KEY`, `FIREBASE_CLIENT_EMAIL`, `FIREBASE_STORAGE_BUCKET`. -3. Select the `Drivers` option, as we have to link to a Node.js App (Question Service). +## Running Question Service without Docker - ![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. - -## 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. +1. Follow the instructions [here](https://nodejs.org/en/download/package-manager) to set up Node v20. -4. After Storage is created, go to `Rules` section and set rule to: +2. Open Command Line/Terminal and navigate into the `question-service` directory. - ``` - rules_version = '2'; - service firebase.storage { - match /b/{bucket}/o { - match /{allPaths=**} { - allow read: if true; - allow write: if request.auth != null; - } - } - } - ``` +3. Run the command: `npm install`. This will install all the necessary dependencies. - 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. +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. Go to `Settings`, `Project settings`, `Service accounts` and click `Generate new private key`. This will download a `.json` file, which will contain your credentials. +## Seeding questions into MongoDB -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`. +1. With Docker -## Running Question Service + - 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`. -1. Follow the instructions [here](https://nodejs.org/en/download/package-manager) to set up Node v20. +2. Without Docker -2. Open Command Line/Terminal and navigate into the `question-service` directory. - -3. Run the command: `npm install`. This will install all the necessary dependencies. + - Run `npm run seed`. -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. +## After running -5. To view Question Service documentation, go to http://localhost:3000/docs. +1. To view Question Service documentation, go to http://localhost:3000/docs. -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. +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/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/db.ts b/backend/question-service/config/db.ts index 20ab0cfe25..e5918d0739 100644 --- a/backend/question-service/config/db.ts +++ b/backend/question-service/config/db.ts @@ -5,12 +5,16 @@ dotenv.config(); const connectDB = async () => { try { - if (process.env.MONGO_URI == undefined) { - throw new Error("MONGO_URI is undefined"); + const 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); - console.log("MongoDB connected"); + await mongoose.connect(mongoDBUri); } catch (error) { console.error(error); process.exit(1); 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/package-lock.json b/backend/question-service/package-lock.json index edc0990cc8..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", @@ -4878,21 +4879,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", @@ -5365,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", @@ -5982,10 +5986,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 +6002,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 +6011,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 +6206,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 +6222,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 +9013,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 +9321,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 +9623,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/backend/question-service/package.json b/backend/question-service/package.json index 1970a6042a..9bdfda8e2e 100644 --- a/backend/question-service/package.json +++ b/backend/question-service/package.json @@ -4,10 +4,11 @@ "main": "server.ts", "type": "module", "scripts": { + "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": "", @@ -38,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/question-service/server.ts b/backend/question-service/server.ts index 5df6e350f2..6e2700160a 100644 --- a/backend/question-service/server.ts +++ b/backend/question-service/server.ts @@ -1,7 +1,21 @@ 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( + `Question service server listening on http://localhost:${PORT}`, + ); + }); + }) + .catch((err) => { + console.error("Failed to connect to DB"); + console.error(err); + }); +} 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/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(); 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/question-service/tests/questionRoutes.spec.ts b/backend/question-service/tests/questionRoutes.spec.ts index f0348471fb..e538f57458 100644 --- a/backend/question-service/tests/questionRoutes.spec.ts +++ b/backend/question-service/tests/questionRoutes.spec.ts @@ -4,6 +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"; @@ -21,31 +27,408 @@ jest.mock("../src/middlewares/basicAccessControl", () => ({ })); describe("Question routes", () => { - it("Delete existing question", async () => { - const title = faker.lorem.lines(1); - const complexity = "Easy"; - const categories = ["Algorithms"]; - const description = faker.lorem.lines(); - const newQuestion = new Question({ - title, - complexity, - category: categories, - description, - }); - await newQuestion.save(); - const res = await request.delete(`${BASE_URL}/${newQuestion.id}`); - expect(res.status).toBe(200); + describe("GET /", () => { + it("Reads existing questions", async () => { + const qnLimit = 10; + const res = await request.get(`${BASE_URL}?page=1&qnLimit=${qnLimit}`); + expect(res.status).toBe(200); + expect(res.body.questions.length).toBeLessThanOrEqual(qnLimit); + }); + + it("Reads existing questions with title filter", async () => { + const qnLimit = 10; + const title = "tree"; + const res = await request.get( + `${BASE_URL}?page=1&qnLimit=${qnLimit}&title=${title}`, + ); + expect(res.status).toBe(200); + expect(res.body.questions.length).toBeLessThanOrEqual(qnLimit); + for (const qn of res.body.questions) { + expect(qn.title.toLowerCase()).toContain(title); + } + }); + + it("Reads existing questions with complexity filter", async () => { + const qnLimit = 10; + const complexity = "Easy"; + const res = await request.get( + `${BASE_URL}?page=1&qnLimit=${qnLimit}&complexities=${complexity}`, + ); + expect(res.status).toBe(200); + expect(res.body.questions.length).toBeLessThanOrEqual(qnLimit); + for (const qn of res.body.questions) { + expect(qn.complexity).toBe(complexity); + } + }); + + it("Reads existing questions with category filters", async () => { + const qnLimit = 10; + const category = "Algorithms"; + const res = await request.get( + `${BASE_URL}?page=1&qnLimit=${qnLimit}&categories=${category}`, + ); + expect(res.status).toBe(200); + expect(res.body.questions.length).toBeLessThanOrEqual(qnLimit); + for (const qn of res.body.questions) { + expect(qn.categories).toContain(category); + } + }); + + it("Does not read without page", async () => { + const res = await request.get(`${BASE_URL}?qnLimit=10`); + expect(res.status).toBe(400); + expect(res.body.message).toBe(PAGE_LIMIT_REQUIRED_MESSAGE); + }); + + it("Does not read without qnLimit", async () => { + const res = await request.get(`${BASE_URL}?page=1`); + expect(res.status).toBe(400); + expect(res.body.message).toBe(PAGE_LIMIT_REQUIRED_MESSAGE); + }); + + it("Does not read with negative page", async () => { + const res = await request.get(`${BASE_URL}?page=-1&qnLimit=10`); + expect(res.status).toBe(400); + expect(res.body.message).toBe(PAGE_LIMIT_INCORRECT_FORMAT_MESSAGE); + }); + + it("Does not read with negative qnLimit", async () => { + const res = await request.get(`${BASE_URL}?page=1&qnLimit=-10`); + expect(res.status).toBe(400); + expect(res.body.message).toBe(PAGE_LIMIT_INCORRECT_FORMAT_MESSAGE); + }); }); - it("Delete non-existing question with invalid object id", async () => { - const res = await request.delete(`${BASE_URL}/blah`); - expect(res.status).toBe(500); - expect(res.body.message).toBe(SERVER_ERROR_MESSAGE); + describe("GET /:id", () => { + it("Reads existing question", async () => { + const title = faker.lorem.lines(1); + const complexity = "Easy"; + const categories = ["Algorithms"]; + const description = faker.lorem.lines(); + const newQuestion = new Question({ + title, + complexity, + category: categories, + description, + }); + await newQuestion.save(); + const res = await request.get(`${BASE_URL}/${newQuestion.id}`); + expect(res.status).toBe(200); + 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 read non-existing question with invalid object id", async () => { + const res = await request.get(`${BASE_URL}/blah`); + expect(res.status).toBe(500); + expect(res.body.message).toBe(SERVER_ERROR_MESSAGE); + }); + + it("Does not read non-existing question with valid object id", async () => { + const res = await request.get(`${BASE_URL}/66f77e9f27ab3f794bdae664`); + expect(res.status).toBe(404); + expect(res.body.message).toBe(QN_NOT_FOUND_MESSAGE); + }); + }); + + describe("GET /categories", () => { + it("Reads existing question categories", async () => { + const res = await request.get(`${BASE_URL}/categories`); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("categories"); + }); + }); + + describe("DELETE /:id", () => { + it("Deletes existing question", async () => { + const title = faker.lorem.lines(1); + const complexity = "Easy"; + const categories = ["Algorithms"]; + const description = faker.lorem.lines(); + const newQuestion = new Question({ + title, + complexity, + category: categories, + description, + }); + await newQuestion.save(); + const res = await request.delete(`${BASE_URL}/${newQuestion.id}`); + expect(res.status).toBe(200); + }); + + it("Does not delete non-existing question with invalid object id", async () => { + const res = await request.delete(`${BASE_URL}/blah`); + expect(res.status).toBe(500); + expect(res.body.message).toBe(SERVER_ERROR_MESSAGE); + }); + + it("Does not delete non-existing question with valid object id", async () => { + const res = await request.delete(`${BASE_URL}/66f77e9f27ab3f794bdae664`); + expect(res.status).toBe(404); + 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); + }); }); - it("Delete non-existing question with valid object id", async () => { - const res = await request.delete(`${BASE_URL}/66f77e9f27ab3f794bdae664`); - expect(res.status).toBe(404); - expect(res.body.message).toBe(QN_NOT_FOUND_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..455506c382 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/.dockerignore b/backend/user-service/.dockerignore new file mode 100644 index 0000000000..4abc77f632 --- /dev/null +++ b/backend/user-service/.dockerignore @@ -0,0 +1,5 @@ +coverage +node_modules +tests +.env* +*.md diff --git a/backend/user-service/.env.sample b/backend/user-service/.env.sample index 5e1c01c1fa..3d6d37715b 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 @@ -11,5 +14,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/Dockerfile b/backend/user-service/Dockerfile new file mode 100644 index 0000000000..0ff78036f7 --- /dev/null +++ b/backend/user-service/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /user-service + +COPY package*.json ./ + +RUN npm ci + +COPY . . + +EXPOSE 3001 + +CMD ["npm", "start"] diff --git a/backend/user-service/MongoDBSetup.md b/backend/user-service/MongoDBSetup.md deleted file mode 100644 index fdb51aa4d2..0000000000 --- a/backend/user-service/MongoDBSetup.md +++ /dev/null @@ -1,59 +0,0 @@ -# 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 cb1d7ef894..3a098f22ab 100644 --- a/backend/user-service/README.md +++ b/backend/user-service/README.md @@ -1,30 +1,18 @@ # User Service Guide -## Setting-up MongoDB +> If you have not set-up either a local or cloud MongoDB, as well as Firebase, visit [this](../README.md) before proceeding. -> :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. +## Setting-up User Service -1. Set up a MongoDB Shared Cluster by following the steps in this [Guide](MongoDBSetup.md). +1. In the `user-service` directory, create a copy of the `.env.sample` file and name it `.env`. -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. +2. To connect to your cloud MongoDB instead of your local MongoDB, set the `NODE_ENV` to `production` instead of `development`. - ![alt text](GuideAssets/ConnectCluster.png) +3. Update `MONGO_CLOUD_URI`, `MONGO_LOCAL_URI`, `FIREBASE_PROJECT_ID`, `FIREBASE_PRIVATE_KEY`, `FIREBASE_CLIENT_EMAIL`, `FIREBASE_STORAGE_BUCKET`, `JWT_SECRET`. -3. Select the `Drivers` option, as we have to link to a Node.js App (User Service). +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. - ![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. - -## 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. @@ -32,10 +20,10 @@ 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. +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. 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. +## After running -6. To view User Service documentation, go to http://localhost:3001/docs. +1. 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. +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. 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 893852899f..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, @@ -73,7 +75,7 @@ export async function createUser( lastName, username, email, - hashedPassword, + hashedPassword ); return res.status(201).json({ message: `Created new user ${username} successfully`, @@ -85,14 +87,41 @@ 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!" }); } } +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; @@ -108,8 +137,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 +154,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 +253,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 +289,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 +314,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!" }); diff --git a/backend/user-service/model/repository.ts b/backend/user-service/model/repository.ts index b08bd05c17..d2387b7871 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; + const 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-lock.json b/backend/user-service/package-lock.json index aa8cb73577..f069da758c 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,10 +30,13 @@ "@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", + "cross-env": "^7.0.3", "eslint": "^9.11.1", "globals": "^15.9.0", "jest": "^29.7.0", @@ -1316,6 +1322,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 +2037,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 +2113,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 +2224,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 +2320,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 +2367,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 +2378,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 +2398,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 +2449,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 +2470,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 +2493,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 +2541,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 +2589,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 +2948,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 +3086,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 +3111,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 +3154,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 +3188,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 +3351,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 +3534,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 +3723,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 +3756,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 +3769,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 +3784,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 +3815,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 +3911,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", @@ -3437,6 +3958,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", @@ -3512,7 +4051,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 +4139,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 +4214,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 +4296,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 +4537,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 +4638,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 +4706,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 +4739,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 +4871,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 +5032,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 +5059,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 +5188,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 +5292,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 +5410,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 +5511,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 +5554,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 +5837,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 +5846,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 +6606,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 +6648,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 +6735,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 +6836,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 +6864,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 +6926,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 +6950,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 +7122,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 +7402,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 +7528,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 +7658,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 +7736,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 +7997,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 +8017,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 +8198,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 +8274,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 +8606,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 +8725,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 +8886,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 +9116,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 +9185,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 +9237,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 +9301,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 +9370,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 +9443,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 +9476,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 +9516,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 +9535,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 +9569,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 b45de63af9..493618b361 100644 --- a/backend/user-service/package.json +++ b/backend/user-service/package.json @@ -5,12 +5,11 @@ "main": "app.ts", "type": "module", "scripts": { - "seed": "tsx scripts/seed.ts", "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": "", @@ -22,10 +21,13 @@ "@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", + "cross-env": "^7.0.3", "eslint": "^9.11.1", "globals": "^15.9.0", "jest": "^29.7.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/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/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/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/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/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(); + }); }); 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..fb0cfc7f34 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,67 @@ +services: + user-service: + image: peerprep/user-service + build: ./backend/user-service + 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 + env_file: ./backend/question-service/.env + ports: + - 3000:3000 + depends_on: + - mongo + - user-service + 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 + + 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 diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000000..9b07ce379e --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +coverage +node_modules +*.md 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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f2a70eaf2a..e2274b3bcc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,8 @@ "axios": "^1.7.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" @@ -6800,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", @@ -7193,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", @@ -10912,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", @@ -11102,6 +11168,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", @@ -11132,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", @@ -11982,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 e53f4ce03f..2343fb3c71 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,8 @@ "axios": "^1.7.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/public/collaborative_editor.png b/frontend/public/collaborative_editor.png new file mode 100644 index 0000000000..e84184e85c Binary files /dev/null and b/frontend/public/collaborative_editor.png differ diff --git a/frontend/public/find_match_form.png b/frontend/public/find_match_form.png new file mode 100644 index 0000000000..b515703a7f Binary files /dev/null and b/frontend/public/find_match_form.png differ diff --git a/frontend/public/match_found.png b/frontend/public/match_found.png new file mode 100644 index 0000000000..df60d6c39a Binary files /dev/null and b/frontend/public/match_found.png differ diff --git a/frontend/public/questions_list.png b/frontend/public/questions_list.png new file mode 100644 index 0000000000..381af8c26a Binary files /dev/null and b/frontend/public/questions_list.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6de5f61c5b..2cb6704908 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,23 +1,29 @@ 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() { return ( }> - } /> + } /> + }> + } /> + } /> } /> @@ -26,7 +32,14 @@ function App() { } /> - } /> + + + + } + /> } /> }> diff --git a/frontend/src/components/ChangePasswordModal/index.tsx b/frontend/src/components/ChangePasswordModal/index.tsx index 0dfd5c3984..cabb9babcd 100644 --- a/frontend/src/components/ChangePasswordModal/index.tsx +++ b/frontend/src/components/ChangePasswordModal/index.tsx @@ -1,136 +1,148 @@ -import { forwardRef, useState } from "react"; -import { Box, Button, Stack, Typography } from "@mui/material"; +import { + Button, + Container, + Dialog, + DialogContent, + DialogTitle, + Stack, + styled, +} from "@mui/material"; +import { useForm } from "react-hook-form"; +import { useProfile } from "../../contexts/ProfileContext"; +import { passwordValidator } from "../../utils/validators"; import PasswordTextField from "../PasswordTextField"; -import { userClient } from "../../utils/api"; -import axios from "axios"; import { - FAILED_PW_UPDATE_MESSAGE, - SUCCESS_PW_UPDATE_MESSAGE, + PASSWORD_MISMATCH_ERROR_MESSAGE, + PASSWORD_REQUIRED_ERROR_MESSAGE, + USE_PROFILE_ERROR_MESSAGE, } from "../../utils/constants"; interface ChangePasswordModalProps { - handleClose: () => void; - userId: string; - onUpdate: ( - isProfileEdit: boolean, - message: string, - isSuccess: boolean, - ) => void; + open: boolean; + onClose: () => 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 ChangePasswordModal: React.FC = (props) => { + const { open, onClose } = props; + const { + register, + handleSubmit, + formState: { errors, dirtyFields, isDirty, isValid }, + watch, + trigger, + } = useForm<{ + oldPassword: string; + newPassword: string; + confirmPassword: string; + }>({ + mode: "all", + }); - const isUpdateDisabled = !( - isCurrPasswordValid && - isNewPasswordValid && - isConfirmPasswordValid - ); + const profile = useProfile(); - const handleSubmit = async () => { - const accessToken = localStorage.getItem("token"); + if (!profile) { + throw new Error(USE_PROFILE_ERROR_MESSAGE); + } - 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 { updatePassword } = profile; return ( - ({ - backgroundColor: theme.palette.common.white, - display: "flex", - width: 600, - flexDirection: "column", - alignItems: "center", - borderRadius: "16px", - padding: "40px", - })} - > - - Change Password - - - - - - - - - + + + Change password + + + + { + updatePassword({ + oldPassword: data.oldPassword, + newPassword: data.newPassword, + }); + onClose(); + })} + > + ({ marginTop: theme.spacing(1) })} + {...register("oldPassword", { + setValueAs: (value: string) => value.trim(), + required: PASSWORD_REQUIRED_ERROR_MESSAGE, + })} + error={!!errors.oldPassword} + helperText={errors.oldPassword?.message} + /> + ({ marginTop: theme.spacing(1) })} + input={watch("newPassword", "")} + {...register("newPassword", { + setValueAs: (value: string) => value.trim(), + validate: { passwordValidator }, + onChange: () => { + if (dirtyFields.confirmPassword) { + trigger("confirmPassword"); + } + }, + })} + error={!!errors.newPassword} + helperText={errors.newPassword?.message} + /> + ({ marginTop: theme.spacing(1) })} + {...register("confirmPassword", { + setValueAs: (value: string) => value.trim(), + validate: { + matchPassword: (value) => + watch("newPassword") === value || + PASSWORD_MISMATCH_ERROR_MESSAGE, + }, + })} + error={!!errors.confirmPassword} + helperText={errors.confirmPassword?.message} + /> + ({ marginTop: theme.spacing(1) })} + > + + + + + + + ); -}); +}; export default ChangePasswordModal; diff --git a/frontend/src/components/CustomTextField/index.tsx b/frontend/src/components/CustomTextField/index.tsx deleted file mode 100644 index 3a235ec427..0000000000 --- a/frontend/src/components/CustomTextField/index.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { Visibility, VisibilityOff } from "@mui/icons-material"; -import { - IconButton, - InputAdornment, - TextField, - TextFieldPropsSizeOverrides, - TextFieldVariants, - Tooltip, -} from "@mui/material"; -import { OverridableStringUnion } from "@mui/types"; -import { useState } from "react"; - -const passwordRequirements = ( -
    -
  • At least 8 characters long
  • -
  • At least 1 lowercase letter
  • -
  • At least 1 uppercase letter
  • -
  • At least 1 digit
  • -
  • At least 1 special character
  • -
-); - -// Adapted from https://muhimasri.com/blogs/mui-validation/ -type CustomTextFieldProps = { - label: string; - variant?: TextFieldVariants; - size?: OverridableStringUnion< - "small" | "medium", - TextFieldPropsSizeOverrides - >; - required?: boolean; - emptyField?: boolean; - validator?: (value: string) => string; - onChange: (value: string, isValid: boolean) => void; - isPasswordField?: boolean; -}; - -const CustomTextField: React.FC = ({ - label, - variant = "outlined", - size = "medium", - required = false, - emptyField = false, - validator, - onChange, - isPasswordField = false, -}) => { - const [error, setError] = useState(""); - const [showPassword, setShowPassword] = useState(!isPasswordField); - - const handleChange = (event: React.ChangeEvent) => { - const input = event.target.value; - - let errorMessage = ""; - if (validator) { - errorMessage = validator(input); - setError(errorMessage); - } - - onChange(input, !errorMessage); - }; - - return ( - - - setShowPassword(!showPassword)} - edge="end" - > - {showPassword ? ( - ({ fontSize: theme.spacing(2.5) })} /> - ) : ( - ({ fontSize: theme.spacing(2.5) })} /> - )} - - - ), - }, - }} - /> - - ); -}; - -export default CustomTextField; diff --git a/frontend/src/components/EditProfileModal/index.tsx b/frontend/src/components/EditProfileModal/index.tsx index 1acedcf9e5..6b51ce4e71 100644 --- a/frontend/src/components/EditProfileModal/index.tsx +++ b/frontend/src/components/EditProfileModal/index.tsx @@ -1,255 +1,328 @@ -import { forwardRef, useState } from "react"; import { - Box, + Avatar, Button, - FormControl, - FormHelperText, + Container, + Dialog, + DialogContent, + DialogTitle, + IconButton, Stack, + styled, TextField, Typography, } from "@mui/material"; -import { userClient } from "../../utils/api"; -import axios from "axios"; +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, - SUCCESS_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 { - handleClose: () => void; + onClose: () => void; + open: boolean; + currProfilePictureUrl?: string; currFirstName: string; currLastName: string; currBiography?: string; - userId: string; - onUpdate: ( - isProfileEdit: boolean, - message: string, - isSuccess: boolean, - ) => 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 EditProfileModal: React.FC = (props) => { + const { + open, + onClose, + currProfilePictureUrl, + currFirstName, + currLastName, + currBiography, + } = props; + + const { + register, + formState: { errors, isValid, isDirty }, + handleSubmit, + setValue, + getFieldState, + } = useForm<{ + profilePic: File | null; + profilePictureUrl: string; + firstName: string; + lastName: string; + biography: string; + }>({ + defaultValues: { + profilePic: null, + profilePictureUrl: currProfilePictureUrl || "", + firstName: currFirstName, + lastName: currLastName, + biography: currBiography || "", + }, + mode: "all", + }); + + const profile = useProfile(); + + if (!profile) { + throw new Error(USE_PROFILE_ERROR_MESSAGE); + } + + 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 + ); + 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", "", { shouldDirty: true }); } - }; - - const isUpdateDisabled = - firstNameError || lastNameError || !checkForChanges(); - - const handleSubmit = async () => { - const accessToken = localStorage.getItem("token"); - - 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); - } + } + }; + + const onClickReset = () => { + if (getFieldState("profilePic").isDirty) { + setValue("profilePic", null, { shouldValidate: true, shouldDirty: true }); + if (hiddenFileInputRef.current) { + hiddenFileInputRef.current.value = ""; } - }; - - 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); + } + if (getFieldState("profilePictureUrl").isDirty) { + setValue("profilePictureUrl", currProfilePictureUrl || "", { + shouldDirty: true, + }); + } + setPicPreview(currProfilePictureUrl || ""); + }; + + const onClickDelete = () => { + if (getFieldState("profilePic").isDirty) { + setValue("profilePic", null, { shouldValidate: true, shouldDirty: true }); + if (hiddenFileInputRef.current) { + hiddenFileInputRef.current.value = ""; + } + } + if (currProfilePictureUrl) { + setValue("profilePictureUrl", "", { shouldDirty: true }); + } + setPicPreview(null); + }; + + return ( + + + Edit profile + + + + { + if (data.profilePic) { + 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).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); + } + }); } else { - setFirstNameError(false); + const url_data = { + firstName: data.firstName, + lastName: data.lastName, + biography: data.biography, + profilePictureUrl: data.profilePictureUrl, + }; + 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(); } - setNewFirstName(val); - }} - error={firstNameError} - /> - {firstNameError ? ( + })} + > ({ marginBottom: theme.spacing(2) })} > - {newFirstName.length == 0 ? ( - - Required field - + {!picPreview ? ( + ) : ( - - Only alphabetical, hyphen and white space characters allowed - + )} - - {newFirstName.length} / {nameCharLimit} characters - + {/* input referenced from https://dreamix.eu/insights/uploading-files-with-react-hook-form/ */} + { + registerRef(e); + hiddenFileInputRef.current = e; + }} + onChange={handleImageChange} + /> + + + + + + + - ) : ( - - {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 ? ( + {errors.profilePic ? ( + + {errors.profilePic.message} + + ) : ( + + {PROFILE_PIC_MAX_SIZE_ERROR_MESSAGE} + + )} + ({ marginTop: theme.spacing(1) })} + {...register("firstName", { + setValueAs: (value: string) => value.trim(), + validate: { nameValidator }, + })} + error={!!errors.firstName} + helperText={errors.firstName?.message} + /> + ({ marginTop: theme.spacing(1) })} + {...register("lastName", { + setValueAs: (value: string) => value.trim(), + validate: { nameValidator }, + })} + error={!!errors.lastName} + helperText={errors.lastName?.message} + /> + ({ marginTop: theme.spacing(1) })} + {...register("biography", { + setValueAs: (value: string) => value.trim(), + validate: { bioValidator }, + })} + /> ({ 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/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(); }); }); diff --git a/frontend/src/components/Navbar/index.tsx b/frontend/src/components/Navbar/index.tsx index c9b58bd5e4..ce782e6a63 100644 --- a/frontend/src/components/Navbar/index.tsx +++ b/frontend/src/components/Navbar/index.tsx @@ -14,22 +14,32 @@ 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); if (!auth) { - throw new Error("useAuth() must be used within AuthProvider"); + throw new Error(USE_AUTH_ERROR_MESSAGE); } const { logout, user } = auth; @@ -62,21 +72,23 @@ 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 ? ( <> - + 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; - }; +import { forwardRef, useState } from "react"; +import { passwordValidators } from "../../utils/validators"; - const comparePasswordError = ( - isMatch: boolean, - password: string, - passwordToMatch: string | undefined - ): boolean => { - return isMatch ? password != passwordToMatch : false; - }; - - const checkEmptyError = (password: string): boolean => { - return !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]); +const TooltipMessage: React.FC<{ + input: string; +}> = (props) => { + const { input } = props; + return ( + + {passwordValidators.map((validator, index) => ( + ({ + padding: theme.spacing(0, 0.2), + alignItems: "flex-start", + })} + > + ({ + minWidth: theme.spacing(4), + paddingLeft: theme.spacing(0.2), + paddingTop: theme.spacing(0.7), + })} + > + {!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), + color: "success.main", + })} + /> + ) : ( + ({ + fontSize: theme.spacing(2.5), + color: "error.main", + })} + /> + )} + + + + ))} + + ); +}; +const PasswordTextField = forwardRef< + HTMLInputElement, + TextFieldProps & { displayTooltip?: boolean; input?: string } +>((props, ref) => { + const { displayTooltip = false, input = "", ...rest } = props; const [showPassword, setShowPassword] = useState(false); + const [openTooltip, setOpenTooltip] = useState(false); + const [isFocused, setIsFocused] = useState(false); - const handleClickShowPassword = () => setShowPassword((show) => !show); - - const handleMouseDownPassword = ( - event: React.MouseEvent - ) => { - event.preventDefault(); + const handleMouseEnter = () => { + setOpenTooltip(true); }; - - const handleMouseUpPassword = ( - event: React.MouseEvent - ) => { - event.preventDefault(); + const handleMouseLeave = () => { + if (!isFocused) { + setOpenTooltip(false); + } }; - - const handlePasswordChange = (event: React.ChangeEvent) => { - const val = event.target.value; - setPassword(val); - setValidity( - !( - validatePasswordError(passwordVal, val) || - comparePasswordError(isMatch, val, passwordToMatch) || - checkEmptyError(val) - ) - ); + const handleFocus = () => { + setIsFocused(true); + setOpenTooltip(true); + }; + const handleBlur = () => { + setIsFocused(false); + setOpenTooltip(false); }; return ( - + } + arrow + placement="right" + > 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) })} + /> + )} ), }, }} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + onFocus={handleFocus} + onBlur={handleBlur} /> - {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/components/ProfileDetails/ProfileDetailstest.tsx b/frontend/src/components/ProfileDetails/ProfileDetailstest.tsx new file mode 100644 index 0000000000..cddf47b020 --- /dev/null +++ b/frontend/src/components/ProfileDetails/ProfileDetailstest.tsx @@ -0,0 +1,41 @@ +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { faker } from "@faker-js/faker"; + +import ProfileDetails from "."; + +faker.seed(0); + +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(); + render( + + ); + expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); + }); + + it("Username is rendered", () => { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + const username = faker.internet.userName(); + const biography = faker.person.bio(); + render( + + ); + expect(screen.getByText(`@${username}`)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ProfileDetails/index.tsx b/frontend/src/components/ProfileDetails/index.tsx new file mode 100644 index 0000000000..d97a8bdd27 --- /dev/null +++ b/frontend/src/components/ProfileDetails/index.tsx @@ -0,0 +1,50 @@ +import { Avatar, Box, Typography } from "@mui/material"; +import React from "react"; + +type ProfileSectionProps = { + profilePictureUrl?: string; + firstName: string; + lastName: string; + username: string; + biography?: string; +}; + +const ProfileDetails: React.FC = (props) => { + const { profilePictureUrl, firstName, lastName, username, biography } = props; + + return ( + + + ({ + display: "flex", + flexDirection: "row", + alignItems: "center", + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + })} + > + + ({ marginLeft: theme.spacing(2) })}> + + {firstName} {lastName} + + @{username} + + + ({ + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + })} + > + {biography} + + + + ); +}; + +export default ProfileDetails; diff --git a/frontend/src/components/ProfileSection/ProfileSection.test.tsx b/frontend/src/components/ProfileSection/ProfileSection.test.tsx deleted file mode 100644 index 1bc2930666..0000000000 --- a/frontend/src/components/ProfileSection/ProfileSection.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { faker } from "@faker-js/faker"; - -import ProfileSection from "."; - -faker.seed(0); - -describe("Profile section", () => { - 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(); - }); - - it("Username 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(`@${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 deleted file mode 100644 index 1b64ed5cb0..0000000000 --- a/frontend/src/components/ProfileSection/index.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Avatar, Box, Button, Divider, Stack, Typography } from "@mui/material"; -import React from "react"; - -type ProfileSectionProps = { - firstName: string; - 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; - - return ( - - - ({ - display: "flex", - flexDirection: "row", - alignItems: "center", - marginTop: theme.spacing(2), - marginBottom: theme.spacing(2), - })} - > - - ({ marginLeft: theme.spacing(2) })}> - - {firstName} {lastName} - - @{username} - - - ({ - marginTop: theme.spacing(2), - marginBottom: theme.spacing(2), - })} - > - {biography} - - - {isCurrentUser && ( - <> - - ({ - marginTop: theme.spacing(4), - marginBottom: theme.spacing(4), - })} - > - - - - - )} - - ); -}; - -export default ProfileSection; diff --git a/frontend/src/components/ProtectedRoutes/index.tsx b/frontend/src/components/ProtectedRoutes/index.tsx index d632df7064..c8d30d9318 100644 --- a/frontend/src/components/ProtectedRoutes/index.tsx +++ b/frontend/src/components/ProtectedRoutes/index.tsx @@ -2,6 +2,8 @@ import { Navigate, Outlet } from "react-router-dom"; 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; @@ -12,9 +14,13 @@ const ProtectedRoutes: React.FC = ({ }) => { const auth = useAuth(); if (!auth) { - throw new Error("useAuth() must be used within AuthProvider"); + throw new Error(USE_AUTH_ERROR_MESSAGE); + } + const { user, loading } = auth; + + if (loading) { + return ; } - const { user } = auth; if (!user) { return ; diff --git a/frontend/src/components/QuestionCategoryAutoComplete/index.tsx b/frontend/src/components/QuestionCategoryAutoComplete/index.tsx index 4f15cd541f..49074de936 100644 --- a/frontend/src/components/QuestionCategoryAutoComplete/index.tsx +++ b/frontend/src/components/QuestionCategoryAutoComplete/index.tsx @@ -26,7 +26,6 @@ const QuestionCategoryAutoComplete: React.FC< multiple freeSolo options={state.questionCategories} - size="small" sx={{ marginTop: 2 }} value={selectedCategories} onChange={(_e, newCategoriesSelected) => { diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 3449cdf9f3..7be6dae74c 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; }; @@ -25,11 +25,13 @@ type AuthContextType = { lastName: string, username: string, email: string, - password: string, + password: string ) => void; login: (email: string, password: string) => void; logout: () => void; user: User | null; + setUser: React.Dispatch>; + loading: boolean; }; const AuthContext = createContext(null); @@ -42,15 +44,20 @@ const AuthProvider: React.FC<{ children?: React.ReactNode }> = (props) => { 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), 1000); - }); + if (accessToken) { + 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); + setTimeout(() => setLoading(false), 500); + } }, []); const signup = ( @@ -58,7 +65,7 @@ const AuthProvider: React.FC<{ children?: React.ReactNode }> = (props) => { lastName: string, username: string, email: string, - password: string, + password: string ) => { userClient .post("/users", { @@ -71,7 +78,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); }); }; @@ -85,11 +92,11 @@ 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); - toast.error(err.response.data.message); + toast.error(err.response?.data.message || err.message); }); }; @@ -105,7 +112,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 new file mode 100644 index 0000000000..cafed8b55d --- /dev/null +++ b/frontend/src/contexts/ProfileContext.tsx @@ -0,0 +1,148 @@ +/* eslint-disable react-refresh/only-export-components */ + +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"; +import axios from "axios"; + +interface UserProfileBase { + firstName: string; + lastName: string; + biography?: string; + profilePictureUrl?: string; +} + +interface UserProfile extends UserProfileBase { + id: string; + username: string; + email: string; + isAdmin: boolean; + createdAt: string; +} + +type ProfileContextType = { + user: UserProfile | null; + editProfileOpen: boolean; + passwordModalOpen: boolean; + fetchUser: (userId: string) => void; + uploadProfilePicture: ( + data: File + ) => Promise<{ message: string; imageUrl: string } | null>; + updateProfile: (data: UserProfileBase) => Promise; + 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 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): Promise => { + const token = localStorage.getItem("token"); + try { + 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)) { + const 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 ({ + 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/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 795f3e4925..9d4a8e6457 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 ( + + ({ + 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. + + + + {images.map((image, i) => ( + + {image.name} + + ))} + + + ); +}; + +export default Landing; 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..047c124cf9 100644 --- a/frontend/src/pages/LogIn/index.tsx +++ b/frontend/src/pages/LogIn/index.tsx @@ -1,52 +1,30 @@ -import { Box, Button, Stack, Typography } from "@mui/material"; +import { Box, Button, 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 { ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; +import { useForm } from "react-hook-form"; +import PasswordTextField from "../../components/PasswordTextField"; +import { + PASSWORD_REQUIRED_ERROR_MESSAGE, + USE_AUTH_ERROR_MESSAGE, +} from "../../utils/constants"; const LogIn: React.FC = () => { const navigate = useNavigate(); const auth = useAuth(); if (!auth) { - throw new Error("useAuth() must be used within AuthProvider"); + throw new Error(USE_AUTH_ERROR_MESSAGE); } 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", { + setValueAs: (value: string) => value.trim(), + validate: { emailValidator }, + })} + error={!!errors.email} + helperText={errors.email?.message} /> - - handleInputChange("password", value, isValid) - } - isPasswordField + fullWidth + margin="normal" + {...register("password", { + setValueAs: (value: string) => value.trim(), + required: PASSWORD_REQUIRED_ERROR_MESSAGE, + })} + error={!!errors.password} + helperText={errors.password?.message} /> diff --git a/frontend/src/pages/NewQuestion/index.tsx b/frontend/src/pages/NewQuestion/index.tsx index bb5f36a2ff..d6731f433d 100644 --- a/frontend/src/pages/NewQuestion/index.tsx +++ b/frontend/src/pages/NewQuestion/index.tsx @@ -34,7 +34,7 @@ const NewQuestion = () => { const [title, setTitle] = useState(""); const [markdownText, setMarkdownText] = useState(""); const [selectedComplexity, setselectedComplexity] = useState( - null, + null ); const [selectedCategories, setSelectedCategories] = useState([]); const [uploadedImagesUrl, setUploadedImagesUrl] = useState([]); @@ -49,7 +49,7 @@ const NewQuestion = () => { ) { if ( !confirm( - "Are you sure you want to leave this page? All process will be lost.", + "Are you sure you want to leave this page? All process will be lost." ) ) { return; @@ -76,7 +76,7 @@ const NewQuestion = () => { complexity: selectedComplexity, categories: selectedCategories, }, - dispatch, + dispatch ); if (result) { @@ -105,7 +105,6 @@ const NewQuestion = () => { { { setselectedComplexity(newcomplexitySelected); diff --git a/frontend/src/pages/Profile/index.tsx b/frontend/src/pages/Profile/index.tsx index 081b342095..896c645b4f 100644 --- a/frontend/src/pages/Profile/index.tsx +++ b/frontend/src/pages/Profile/index.tsx @@ -1,58 +1,52 @@ 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/ProfileDetails"; +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"; - -type UserProfile = { - id: string; - username: string; - firstName: string; - lastName: string; - email: string; - isAdmin: boolean; - biography?: string; - profilePictureUrl?: string; - createdAt: string; -}; +import { useProfile } from "../../contexts/ProfileContext"; +import { + USE_AUTH_ERROR_MESSAGE, + USE_PROFILE_ERROR_MESSAGE, +} from "../../utils/constants"; 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 [userProfile, setUserProfile] = useState(null); const auth = useAuth(); + const profile = useProfile(); + if (!auth) { - throw new Error("useAuth() must be used within AuthProvider"); + throw new Error(USE_AUTH_ERROR_MESSAGE); } - const { user } = auth; + if (!profile) { + throw new Error(USE_PROFILE_ERROR_MESSAGE); + } + + const { + user, + editProfileOpen, + passwordModalOpen, + fetchUser, + setEditProfileModalOpen, + setPasswordModalOpen, + } = profile; useEffect(() => { - userClient - .get(`/users/${userId}`) - .then((res) => { - setUserProfile(res.data.data); - }) - .catch(() => setUserProfile(null)); + if (!userId) { + return; + } + + fetchUser(userId); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isProfileChanged]); + }, []); - 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 + + {editProfileOpen && ( + setEditProfileModalOpen(false)} + currProfilePictureUrl={user.profilePictureUrl} + currFirstName={user.firstName} + currLastName={user.lastName} + currBiography={user.biography} + /> + )} + {passwordModalOpen && ( + setPasswordModalOpen(false)} + /> + )} + + ); }; diff --git a/frontend/src/pages/QuestionEdit/index.tsx b/frontend/src/pages/QuestionEdit/index.tsx index 0699f5dd8a..c3eb79e048 100644 --- a/frontend/src/pages/QuestionEdit/index.tsx +++ b/frontend/src/pages/QuestionEdit/index.tsx @@ -37,7 +37,7 @@ const QuestionEdit = () => { const [title, setTitle] = useState(""); const [markdownText, setMarkdownText] = useState(""); const [selectedComplexity, setselectedComplexity] = useState( - null, + null ); const [selectedCategories, setSelectedCategories] = useState([]); const [uploadedImagesUrl, setUploadedImagesUrl] = useState([]); @@ -63,7 +63,7 @@ const QuestionEdit = () => { const handleBack = () => { if ( !confirm( - "Are you sure you want to leave this page? All process will be lost.", + "Are you sure you want to leave this page? All process will be lost." ) ) { return; @@ -104,7 +104,7 @@ const QuestionEdit = () => { complexity: selectedComplexity, categories: selectedCategories, }, - dispatch, + dispatch ); if (result) { @@ -133,7 +133,6 @@ const QuestionEdit = () => { { { diff --git a/frontend/src/pages/QuestionList/index.tsx b/frontend/src/pages/QuestionList/index.tsx index 000d44d56c..21cf275692 100644 --- a/frontend/src/pages/QuestionList/index.tsx +++ b/frontend/src/pages/QuestionList/index.tsx @@ -33,6 +33,7 @@ import { complexityList, FAILED_QUESTION_DELETE, SUCCESS_QUESTION_DELETE, + USE_AUTH_ERROR_MESSAGE, } from "../../utils/constants"; import useDebounce from "../../utils/debounce"; import { blue, grey } from "@mui/material/colors"; @@ -66,6 +67,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 +118,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 % rowsPerPage !== 1 || page === 0) { + updateQuestionList(); + } else { + setPage(page => page - 1); + } }; useEffect(() => { @@ -137,14 +135,16 @@ 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 const auth = useAuth(); if (!auth) { - throw new Error("useAuth() must be used within AuthProvider"); + throw new Error(USE_AUTH_ERROR_MESSAGE); } const { user } = auth; const isAdmin = user && user.isAdmin; 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..0736e57f3f 100644 --- a/frontend/src/pages/SignUp/index.tsx +++ b/frontend/src/pages/SignUp/index.tsx @@ -1,73 +1,42 @@ -import { Box, Button, Stack, Typography } from "@mui/material"; +import { Box, Button, 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 { ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; +import { useForm } from "react-hook-form"; +import PasswordTextField from "../../components/PasswordTextField"; +import { + PASSWORD_REQUIRED_ERROR_MESSAGE, + USE_AUTH_ERROR_MESSAGE, +} from "../../utils/constants"; const SignUp: React.FC = () => { const navigate = useNavigate(); const auth = useAuth(); if (!auth) { - throw new Error("useAuth() must be used within AuthProvider"); + throw new Error(USE_AUTH_ERROR_MESSAGE); } 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, + watch, + 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", { + setValueAs: (value: string) => value.trim(), + validate: { nameValidator }, + })} + error={!!errors.firstName} + helperText={errors.firstName?.message} /> - - handleInputChange("lastName", value, isValid) - } + fullWidth + margin="normal" + {...register("lastName", { + setValueAs: (value: string) => value.trim(), + validate: { nameValidator }, + })} + error={!!errors.lastName} + helperText={errors.lastName?.message} /> - - handleInputChange("username", value, isValid) - } + fullWidth + margin="normal" + {...register("username", { + setValueAs: (value: string) => value.trim(), + validate: { usernameValidator }, + })} + error={!!errors.username} + helperText={errors.username?.message} /> - - handleInputChange("email", value, isValid) - } + fullWidth + margin="normal" + type="email" + {...register("email", { + setValueAs: (value: string) => value.trim(), + validate: { emailValidator }, + })} + error={!!errors.email} + helperText={errors.email?.message} /> - - handleInputChange("password", value, isValid) - } - isPasswordField + fullWidth + margin="normal" + input={watch("password", "")} + {...register("password", { + setValueAs: (value: string) => value.trim(), + required: PASSWORD_REQUIRED_ERROR_MESSAGE, + validate: { passwordValidator }, + })} + error={!!errors.password} + helperText={errors.password?.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; }); diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index bb716be5f3..1b7739f405 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -26,6 +26,8 @@ const theme = createTheme({ }, }, }, + MuiTextField: { defaultProps: { size: "small" } }, + MuiAutocomplete: { defaultProps: { size: "small" } }, MuiCssBaseline: { styleOverrides: { html: { @@ -45,6 +47,13 @@ const theme = createTheme({ }, }, }, + MuiListItemText: { + styleOverrides: { + primary: { + fontSize: "14px", + }, + }, + } }, }); diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index af13139f20..0a0aa8fea8 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -2,6 +2,52 @@ export const complexityList: string[] = ["Easy", "Medium", "Hard"]; export const languageList = ["Python", "Java"]; +/* 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"; +export const NAME_MAX_LENGTH_ERROR_MESSAGE = + "Name must be at most 50 characters long"; +export const NAME_ALLOWED_CHAR_ERROR_MESSAGE = + "Name must contain only alphabetical, hyphen and white space characters"; + +/* Username Validation */ +export const USERNAME_LENGTH_ERROR_MESSAGE = + "Username must be between 6 and 30 characters long"; +export const USERNAME_ALLOWED_CHAR_ERROR_MESSAGE = + "Username must contain only alphanumeric, underscore and full stop characters"; + +/* Email Validation */ +export const EMAIL_REQUIRED_ERROR_MESSAGE = "Email is required"; +export const EMAIL_INVALID_ERROR_MESSAGE = "Email is invalid"; + +/* Biography Validation */ +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 = + "Password must be at least 8 characters long"; +export const PASSWORD_LOWER_CASE_ERROR_MESSAGE = + "Password must contain at least 1 lowercase letter"; +export const PASSWORD_UPPER_CASE_ERROR_MESSAGE = + "Password must contain at least 1 uppercase letter"; +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_ERROR_MESSAGE = "Password is weak"; +export const PASSWORD_MISMATCH_ERROR_MESSAGE = "Password does not match"; + /* Toast Messages */ // Authentication export const SUCCESS_LOG_OUT = "Logged out successfully!"; @@ -29,3 +75,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"; diff --git a/frontend/src/utils/validators.ts b/frontend/src/utils/validators.ts index b7bb30a78c..bec4fdab48 100644 --- a/frontend/src/utils/validators.ts +++ b/frontend/src/utils/validators.ts @@ -1,61 +1,121 @@ /* eslint-disable */ +import { + BIO_MAX_LENGTH_ERROR_MESSAGE, + EMAIL_INVALID_ERROR_MESSAGE, + EMAIL_REQUIRED_ERROR_MESSAGE, + NAME_ALLOWED_CHAR_ERROR_MESSAGE, + NAME_MAX_LENGTH_ERROR_MESSAGE, + NAME_REQUIRED_ERROR_MESSAGE, + PASSWORD_DIGIT_ERROR_MESSAGE, + PASSWORD_LOWER_CASE_ERROR_MESSAGE, + PASSWORD_MIN_LENGTH_ERROR_MESSAGE, + 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"; + export const nameValidator = (value: string) => { if (value.length === 0) { - return "Name must not be empty"; + return NAME_REQUIRED_ERROR_MESSAGE; } if (value.length > 50) { - return "Name must be at most 50 characters long"; + return NAME_MAX_LENGTH_ERROR_MESSAGE; } if (!/^[a-zA-Z\s-]*$/.test(value)) { - return "Name must contain only alphabetical, hyphen and white space characters"; + return NAME_ALLOWED_CHAR_ERROR_MESSAGE; } - return ""; + return true; }; export const usernameValidator = (value: string) => { if (value.length < 6 || value.length > 30) { - return "Username must be between 6 and 30 characters long"; + return USERNAME_LENGTH_ERROR_MESSAGE; } if (!/^[a-zA-Z0-9._]+$/.test(value)) { - return "Username must contain only alphanumeric, underscore and full stop characters"; + return USERNAME_ALLOWED_CHAR_ERROR_MESSAGE; } - return ""; + return true; }; export const emailValidator = (value: string) => { + if (value.length === 0) { + return EMAIL_REQUIRED_ERROR_MESSAGE; + } + if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value)) { - return "Email is invalid"; + return EMAIL_INVALID_ERROR_MESSAGE; } - return ""; + return true; }; -export const passwordValidator = (value: string) => { - if (value.length < 8) { - return "Password must be at least 8 characters long"; +export const bioValidator = (value: string) => { + if (value.length > 255) { + return BIO_MAX_LENGTH_ERROR_MESSAGE; } - if (!/[a-z]/.test(value)) { - return "Password must contain at least 1 lowercase letter"; - } + return true; +}; - if (!/[A-Z]/.test(value)) { - return "Password must contain at least 1 uppercase letter"; +export const profilePictureValidator = (value: File | null) => { + if (value && value.size > 5 * 1024 * 1024) { + return PROFILE_PIC_MAX_SIZE_ERROR_MESSAGE; } - if (!/\d/.test(value)) { - return "Password must contain at least 1 digit"; - } + return true; +}; + +const minLengthValidator = (value: string) => { + return value.length >= 8; +}; - if (!/[ `!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/.test(value)) { - return "Password must contain at least 1 special character"; +const lowerCaseValidator = (value: string) => { + return /[a-z]/.test(value); +}; + +const upperCaseValidator = (value: string) => { + return /[A-Z]/.test(value); +}; + +const digitValidator = (value: string) => { + return /\d/.test(value); +}; + +const specialCharValidator = (value: string) => { + return /[ `!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/.test(value); +}; + +export const passwordValidator = (value: string) => { + if ( + value && + (!minLengthValidator(value) || + !lowerCaseValidator(value) || + !upperCaseValidator(value) || + !digitValidator(value) || + !specialCharValidator(value)) + ) { + return PASSWORD_WEAK_ERROR_MESSAGE; } - return ""; + return true; }; + +export const passwordValidators = [ + { validate: minLengthValidator, message: PASSWORD_MIN_LENGTH_ERROR_MESSAGE }, + { validate: lowerCaseValidator, message: PASSWORD_LOWER_CASE_ERROR_MESSAGE }, + { validate: upperCaseValidator, message: PASSWORD_UPPER_CASE_ERROR_MESSAGE }, + { validate: digitValidator, message: PASSWORD_DIGIT_ERROR_MESSAGE }, + { + validate: specialCharValidator, + message: PASSWORD_SPECIAL_CHAR_ERROR_MESSAGE, + }, +]; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ba3d0846cb..20afd8d241 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,4 +5,8 @@ import svgr from "vite-plugin-svgr"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react(), svgr()], + optimizeDeps: { include: ["@mui/material", "@mui/icons-material"] }, + server: { + host: true, + }, }); 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" + } +}