diff --git a/.github/workflows/backend-deploy-coverage-report.yaml b/.github/workflows/backend-deploy-coverage-report.yaml new file mode 100644 index 0000000..0ac267d --- /dev/null +++ b/.github/workflows/backend-deploy-coverage-report.yaml @@ -0,0 +1,64 @@ +name: Backend - Deploy coverage report to Pages + +on: + push: + branches: ['main'] + + workflow_dispatch: + +concurrency: + group: 'pages' + cancel-in-progress: true + +jobs: + deploy: + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: insighthub-backend + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Create envfile + uses: SpicyPizza/create-envfile@v2.0.3 + with: + envkey_PORT: 3000 + envkey_DB_CONNECTION: mongodb://localhost:27017/app + envkey_ACCESS_TOKEN_SECRET: 2a955a8b802eab6c693ba116f5e1bc8d2d2cf5e5e83e579cecb3fc363fb077229a801e7392f348d3c370e2383169cab80f556421d3ec2186def5d36ed5a317b6 + envkey_TOKEN_EXPIRATION: 30s + envkey_REFRESH_TOKEN_SECRET: 2a955a8b802eab6c693ba116f5e1bc8d2d2cf5e5e83e579cecb3fc363fb077229a801e7392f348d3c370e2383169cab80f556421d3ec2186def5d36ed5a317b6 + envkey_REFRESH_TOKEN_EXPIRATION: 1d + file_name: insighthub-backend/.env + fail_on_empty: true + sort_keys: false + - name: Start MongoDB + uses: supercharge/mongodb-github-action@1.10.0 + with: + mongodb-version: '6.0' + - uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Install dependencies + run: npm i + - name: Run tests + run: npm run test -- --testTimeout=30000 + - name: Setup Pages + if: always() + uses: actions/configure-pages@v4 + - name: Upload Artifact + if: always() + uses: actions/upload-pages-artifact@v3 + with: + path: 'insighthub-backend/coverage/lcov-report' + name: 'github-pages' + - name: Deploy to GitHub Pages + if: always() + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/backend-run-all-tests.yaml b/.github/workflows/backend-run-all-tests.yaml new file mode 100644 index 0000000..e894f79 --- /dev/null +++ b/.github/workflows/backend-run-all-tests.yaml @@ -0,0 +1,41 @@ +name: Backend - Run all tests + +on: + push: + branches: ['*'] + pull_request: + branches: ['*'] + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: insighthub-backend + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Create envfile + uses: SpicyPizza/create-envfile@v2.0.3 + with: + envkey_PORT: 3000 + envkey_DB_CONNECTION: mongodb://localhost:27017/app + envkey_ACCESS_TOKEN_SECRET: 2a955a8b802eab6c693ba116f5e1bc8d2d2cf5e5e83e579cecb3fc363fb077229a801e7392f348d3c370e2383169cab80f556421d3ec2186def5d36ed5a317b6 + envkey_TOKEN_EXPIRATION: 30s + envkey_REFRESH_TOKEN_SECRET: 2a955a8b802eab6c693ba116f5e1bc8d2d2cf5e5e83e579cecb3fc363fb077229a801e7392f348d3c370e2383169cab80f556421d3ec2186def5d36ed5a317b6 + envkey_REFRESH_TOKEN_EXPIRATION: 1d + envkey_CHAT_AI_TURNED_ON: false + file_name: insighthub-backend/.env + fail_on_empty: true + sort_keys: false + - name: Start MongoDB + uses: supercharge/mongodb-github-action@1.10.0 + with: + mongodb-version: '6.0' + - uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Install dependencies + run: npm i + - name: Run tests + run: npm run test -- --testTimeout=30000 diff --git a/.github/workflows/frontend-run-build.yaml b/.github/workflows/frontend-run-build.yaml new file mode 100644 index 0000000..1dd3464 --- /dev/null +++ b/.github/workflows/frontend-run-build.yaml @@ -0,0 +1,32 @@ +name: Frontend - Run build + +on: + push: + branches: ['*'] + pull_request: + branches: ['*'] + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: insighthub-frontend + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Create envfile + uses: SpicyPizza/create-envfile@v2.0.3 + with: + envkey_VITE_PORT: 5000 + envkey_VITE_BACKEND_URL: http://localhost:3000 + file_name: insighthub-frontend/.env + fail_on_empty: true + sort_keys: false + - uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Install dependencies + run: npm i + - name: Run build + run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48bbc48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,133 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ +package-lock.json + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* +/.idea +.DS_Store diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7be9c10 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,29 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node-terminal", + "name": "Backend: Run Script: dev", + "request": "launch", + "command": "npm run dev", + "cwd": "${workspaceFolder}\\insighthub-backend" + }, + { + "type": "node-terminal", + "name": "Backend: Run Script: test", + "request": "launch", + "command": "npm run test", + "cwd": "${workspaceFolder}\\insighthub-backend" + }, + { + "type": "node-terminal", + "name": "Frontend: Run Script: dev", + "request": "launch", + "command": "npm run dev", + "cwd": "${workspaceFolder}\\insighthub-frontend" + } + ] +} \ No newline at end of file diff --git a/insighthub-backend/.env.template b/insighthub-backend/.env.template new file mode 100644 index 0000000..c325bb8 --- /dev/null +++ b/insighthub-backend/.env.template @@ -0,0 +1,14 @@ +## This is an extra file + +PORT= +DB_CONNECTION= +ACCESS_TOKEN_SECRET= +TOKEN_EXPIRATION= +REFRESH_TOKEN_EXPIRATION= +FRONTEND_URL= +BACKEND_URL= +FIREBASE_SERVICE_ACCOUNT={"type": "service_account","project_id": ,"private_key_id": ,"private_key": ,"client_email": ,"client_id": ,"auth_uri": "https://accounts.google.com/o/oauth2/auth","token_uri": "https://oauth2.googleapis.com/token","auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url": <>,"universe_domain": "googleapis.com"} +OPENROUTER_API_KEY= +CHAT_AI_API_URL= +OPENROUTER_MODEL_NAME= +CHAT_AI_TURNED_ON= diff --git a/insighthub-backend/.gitignore b/insighthub-backend/.gitignore new file mode 100644 index 0000000..8bb6356 --- /dev/null +++ b/insighthub-backend/.gitignore @@ -0,0 +1,17 @@ +# IDEs +.idea/ +.vscode/ + +# Node +node_modules/ +dist/ +package-lock.json +openssl-secret.txt +resources + +# Environment +.env + +# Tests +**/tests/reports +coverage diff --git a/insighthub-backend/README.md b/insighthub-backend/README.md new file mode 100644 index 0000000..8e06a5f --- /dev/null +++ b/insighthub-backend/README.md @@ -0,0 +1,117 @@ +# InsightHub Backend + +## Git Subtree + +This `insighthub-backend` directory was initialized by cloning [`colman-advanced-web-apps`](https://github.com/taljacob2/colman-advanced-web-apps) repository as a [git subtree](https://www.atlassian.com/git/tutorials/git-subtree), by running the following command: + +``` +cd .. +git subtree add -P insighthub-backend https://github.com/taljacob2/colman-advanced-web-apps master --squash +``` + +### Check For Updates From [`colman-advanced-web-apps`](https://github.com/taljacob2/colman-advanced-web-apps) Repository + +To upgrade the existing backend with the most recent version available in the [`colman-advanced-web-apps`](https://github.com/taljacob2/colman-advanced-web-apps) repository, merge it into this `insighthub-backend` directory: + +``` +cd .. +git subtree pull -P insighthub-backend https://github.com/taljacob2/colman-advanced-web-apps master --squash +``` + +## Prerequisites + +### Configure Environment + +1. As a **requirement** for running the application, create an `.env` file in the `insighthub-backend` working directory. Copy the content of the [.env.template](/insighthub-backend/.env.template) file to your newly created `.env` file. Define the environment variables there. + +1. Edit the values of the properties to match your environment. + + > In case you want to run a docker environment, see our guide for [how to setup a docker environment](/insighthub-backend/docs/mongodb/mongodb-via-docker.md). + +## `.env` + +### `PORT` + +The port number to serve the node server on when executing the `npm run dev` or `npm start` commands. + +For example `3000`. + +### `DB_CONNECTION` + +The connection string to the mongodb database. + +### `FRONTEND_URL` + +The url to the frontend that the app uses, to allow cors + +### `BACKEND_URL` + +The url to the backend for swagger usage + +### `ACCESS_TOKEN_SECRET` + +To generate a secret key for access and refresh token, execite this command in your terminal: + +``` +openssl rand -out openssl-secret.txt -hex 64 +``` + +The secret will be stored in the `openssl-secret.txt` file. + +Navigate to the `.env` file and set the `ACCESS_TOKEN_SECRET` variable to that token value. + +### `TOKEN_EXPIRATION` + +Determines the expiration time for the authentication token. + +It is composed of ``. + +for example: + +- `30s` - is 30 seconds +- `30m` - is 30 minutes +- `30h` - is 30 hours + +You can set the NUMBER and the TIME_UNIT to your liking. + +### `REFRESH_TOKEN_EXPIRATION` + +Determines the expiration time for the authentication refresh token. + +You should define it in the same way as [`TOKEN_EXPIRATION`](https://github.com/Lina0Elman/InsightHub?tab=readme-ov-file#token_expiration). + +## Usage + +Install the required node packages: + +``` +npm i +``` + +Start the appliation: + +``` +npm start +``` + +### Development + +Run the application with nodemon: + +``` +npm run dev +``` + +Run tests: + +``` +npm run test +``` + +See our `main` test coverage at https://Lina0Elman.github.io/InsightHub + +## Documentation + +See the Swagger documentation in the `/api-docs` route. + +See more docs [here](/insighthub-backend/docs). \ No newline at end of file diff --git a/insighthub-backend/babel.config.js b/insighthub-backend/babel.config.js new file mode 100644 index 0000000..a50f080 --- /dev/null +++ b/insighthub-backend/babel.config.js @@ -0,0 +1,6 @@ +module.exports = { + presets: [ + ['@babel/preset-env', {targets: {node: 'current'}}], + '@babel/preset-typescript', + ], + }; \ No newline at end of file diff --git a/insighthub-backend/docker-compose.yaml b/insighthub-backend/docker-compose.yaml new file mode 100644 index 0000000..bfa65d1 --- /dev/null +++ b/insighthub-backend/docker-compose.yaml @@ -0,0 +1,30 @@ +version: "3.8" + +services: + + mongodb: + image: mongo + restart: "on-failure" + ports: + - $DB_LOCAL_PORT:$DB_DOCKER_PORT + volumes: + - mongodb_data:/data/db + environment: + - MONGO_INITDB_ROOT_USERNAME=$DB_USER + - MONGO_INITDB_ROOT_PASSWORD=$DB_PASSWORD + - MONGO_DB_CONNECTION_STRING=$MONGO_DOCKER_URI # optional for debugging via mongosh + + mongoexpress: + image: mongo-express + restart: "on-failure" + ports: + - $DB_UI_LOCAL_PORT:$DB_UI_DOCKER_PORT + environment: + - ME_CONFIG_MONGODB_ADMINUSERNAME=$DB_USER + - ME_CONFIG_MONGODB_ADMINPASSWORD=$DB_PASSWORD + - ME_CONFIG_MONGODB_SERVER=mongodb + - ME_CONFIG_BASICAUTH_USERNAME=$DB_USER + - ME_CONFIG_BASICAUTH_PASSWORD=$DB_PASSWORD + +volumes: + mongodb_data: {} \ No newline at end of file diff --git a/insighthub-backend/docs/mongodb/mongodb-via-docker.md b/insighthub-backend/docs/mongodb/mongodb-via-docker.md new file mode 100644 index 0000000..a706e34 --- /dev/null +++ b/insighthub-backend/docs/mongodb/mongodb-via-docker.md @@ -0,0 +1,87 @@ +# MongoDB Via Docker + +In case you want to run a local [mongodb](https://www.mongodb.com/) server, while developing, +there is an option to spin up a mongodb server via a [docker](https://www.docker.com/) container. + +It may be more convenient for you to do so instead of manually installing a mongodb +server on your local machine, but this issue is entirely up to personal taste. + +> In case you are not familiar with docker, you can get started with docker [here](https://docs.docker.com/get-started/) +## Prerequsites + +To run mongodb via a docker container, first you must install [docker](https://www.docker.com/) on your machine. + +## `docker-compose` + +`docker-compose` is a command in docker, that boots up multiple docker images as a "network". +So you could boot up this network at once, and shut it down at once - and all the containers will boot up or shut down at once. + +Also, within a network, you can refer one container to another, to depend on each other, or share environment variables together. + +To use the `docker-compose` command you require a `docker-compose.yaml` file, for it to read the network configuration you want it to establish. + +We have pre-made a [`docker-compose.yaml`](/insighthub-backend/docker-compose.yaml) file for you to use, +that spins up local [mongodb](https://hub.docker.com/_/mongo) (DB) and [mongo-express](https://hub.docker.com/_/mongo-express) (UI) containers via docker. + +## Usage + +### Update Your [`.env`](/insighthub-backend/.env) File For [`docker-compose.yaml`](/insighthub-backend/docker-compose.yaml) + +Before using [`docker-compose.yaml`](/insighthub-backend/docker-compose.yaml), you need to navigate to your [`.env`](/insighthub-backend/.env) file, +and update it by adding the following properties: + +``` +# DB (mongodb) +DB_USER=rootuser +DB_PASSWORD=rootpass +DB_DOCKER_PORT=27017 +DB_LOCAL_PORT=27016 +DB_HOST=localhost +DB_NAME=app +MONGO_DOCKER_URI=mongodb://${DB_USER}:${DB_PASSWORD}@mongodb:${DB_DOCKER_PORT}/${DB_NAME}?authSource=admin +MONGO_URI=mongodb://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_LOCAL_PORT}/${DB_NAME}?authSource=admin +DB_CONNECTION=${MONGO_URI} + +# DB UI (mongoexpress) +DB_UI_DOCKER_PORT=8081 +DB_UI_LOCAL_PORT=8061 +``` + +### Run `docker-compose` + +- Run the network with: + + ``` + docker-compose up -d + ``` + Then you would have: + - mongodb listening on port `27016` so you could access the db with the connection string of + `mongodb://rootuser:rootpass@localhost:27016/app?authSource=admin` + - mongo-express opening at http://localhost:8061 +- Stop the network with: + ``` + docker-compose down + ``` +### OPTIONAL: Run Raw Queries From `mongosh` +You may run raw queries for mongodb, via the `mongosh` of the `mongodb` container. +Once you `docker-compose up -d` this [`docker-compose.yaml`](/insighthub-backend/docker-compose.yaml) file, +and the `mongodb` container is running, then execute the following commands: +View all the running containers: +``` +docker ps +``` +Copy the `mongodb` container name, and then, enter the `mongodb` container `/bin/bash`: +``` +docker exec -it /bin/bash +``` +And then, enter the `mongosh` app with the authentication of the `$MONGO_DB_CONNECTION_STRING` connection string: +``` +mongosh $MONGO_DB_CONNECTION_STRING +``` +This will lead you to the `mongosh` app within the `mongodb` container, with the correct authentication. +And there you can execute raw queries in the database. +For example, you can test the connection, by executing: +``` +show dbs +``` +And this will list all the databases in the server. \ No newline at end of file diff --git a/insighthub-backend/eslint.config.mjs b/insighthub-backend/eslint.config.mjs new file mode 100644 index 0000000..ee6118c --- /dev/null +++ b/insighthub-backend/eslint.config.mjs @@ -0,0 +1,12 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import tseslint from "typescript-eslint"; + + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + {files: ["**/*.{js,mjs,cjs,ts}"]}, + {languageOptions: { globals: {...globals.browser, ...globals.node} }}, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, +]; \ No newline at end of file diff --git a/insighthub-backend/jest.config.ts b/insighthub-backend/jest.config.ts new file mode 100644 index 0000000..16729c3 --- /dev/null +++ b/insighthub-backend/jest.config.ts @@ -0,0 +1,208 @@ +/** + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/configuration + */ + +import type {Config} from 'jest'; + + +const config: Config = { + testTimeout: 10 * 1000, + passWithNoTests: true, + watchAll: false, + detectOpenHandles: true, + forceExit: true, + + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "C:\\Users\\Tal\\AppData\\Local\\Temp\\jest", + + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // An array of regexp pattern strings used to skip coverage collection + coveragePathIgnorePatterns: [ + "\\\\node_modules\\\\", + ".*dist.*" + ], + + // Indicates which provider should be used to instrument code for coverage + // coverageProvider: "babel", + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // The default configuration for fake timers + // fakeTimers: { + // "enableGlobally": false + // }, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "mjs", + // "cjs", + // "jsx", + // "ts", + // "tsx", + // "json", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + // moduleNameMapper: {}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: undefined, + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state before every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state and implementation before every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + setupFilesAfterEnv: ['./src/tests/setup-tests.ts'], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + // testEnvironment: "jest-environment-node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + testPathIgnorePatterns: [ + "\\\\node_modules\\\\", + ".*dist.*" + ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jest-circus/runner", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "\\\\node_modules\\\\", + // "\\.pnp\\.[^\\\\]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; + +export default config; diff --git a/insighthub-backend/package.json b/insighthub-backend/package.json new file mode 100644 index 0000000..78cf194 --- /dev/null +++ b/insighthub-backend/package.json @@ -0,0 +1,70 @@ +{ + "name": "ex", + "version": "1.0.0", + "description": "InsightHub Backend", + "main": "src/server.ts", + "scripts": { + "start": "tsc && node ./dist/server.js", + "dev": "nodemon ./src/server.ts", + "test": "cross-env NODE_ENV=test jest --runInBand", + "testAuth": "cross-env NODE_ENV=test jest --runInBand auth_controller.test.ts" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Lina0Elman/InsightHub.git" + }, + "author": "Mevorah Berrebi & Tal Jacob & Lina Elman & Liav Tibi", + "license": "ISC", + "bugs": { + "url": "https://github.com/Lina0Elman/InsightHub/issues" + }, + "homepage": "https://github.com/Lina0Elman/InsightHub#readme", + "dependencies": { + "bcrypt": "^5.1.1", + "body-parser": "^1.20.3", + "cors": "^2.8.5", + "cross-env": "^7.0.3", + "dotenv": "^16.4.5", + "dotenv-expand": "^12.0.1", + "express": "^4.21.1", + "express-unless": "2.1.3", + "express-validator": "^7.2.0", + "firebase-admin": "^13.2.0", + "jest": "^29.7.0", + "jest-junit": "^16.0.0", + "js-yaml": "^4.1.0", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.8.2", + "multer": "^1.4.5-lts.1", + "socket.io": "^4.8.1", + "supertest": "^7.0.0", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", + "axios": "^1.8.3" + }, + "devDependencies": { + "@babel/preset-env": "^7.26.0", + "@babel/preset-typescript": "^7.26.0", + "@eslint/js": "^9.17.0", + "@types/bcrypt": "^5.0.2", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.1", + "@types/express-unless": "2.0.3", + "@types/jest": "^29.5.14", + "@types/js-yaml": "^4.0.9", + "@types/jsonwebtoken": "^9.0.7", + "@types/multer": "^1.4.12", + "@types/supertest": "^6.0.2", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.7", + "eslint": "^9.18.0", + "globals": "^15.14.0", + "mongodb-memory-server": "^10.1.2", + "nodemon": "^3.1.7", + "swagger-ui-express": "^5.0.1", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "typescript": "^5.7.3", + "typescript-eslint": "^8.18.2" + } +} diff --git a/insighthub-backend/src/app.ts b/insighthub-backend/src/app.ts new file mode 100644 index 0000000..2942509 --- /dev/null +++ b/insighthub-backend/src/app.ts @@ -0,0 +1,81 @@ +import express, {Request, Response, NextFunction} from 'express'; +import authRoutes from './routes/auth_routes'; +import commentsRoutes from './routes/comments_routes'; +import postsRoutes from './routes/posts_routes'; +import usersRoutes from './routes/users_routes'; +import swaggerUi, {JsonObject} from 'swagger-ui-express'; +import swaggerJsdoc from 'swagger-jsdoc'; +import options from './docs/swagger_options'; +import {authenticateToken, authenticateTokenForParams} from "./middleware/auth"; +import bodyParser from 'body-parser'; +import roomsRoutes from './routes/rooms_routes'; +import cors from 'cors'; +import {config} from "./config/config"; +import validateUser from "./middleware/validateUser"; +import loadOpenApiFile from "./openapi/openapi_loader"; +import resource_routes from './routes/resources_routes'; + + +const specs = swaggerJsdoc(options); + +const app = express(); + +const corsOptions = { + origin: [config.app.frontend_url(), config.app.backend_url()], + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', + credentials: true, // Allow cookies to be sent with requests +}; + +app.use(cors(corsOptions)); + +const removeUndefinedOrEmptyFields = (req: Request, res: Response, next: NextFunction) => { + if (req.body && typeof req.body === 'object') { + for (const key in req.body) { + if (req.body[key] === undefined || req.body[key] === null || req.body[key] === '') { + delete req.body[key]; + } + } + } + next(); +}; + +app.use(bodyParser.json()); +app.use(removeUndefinedOrEmptyFields); +app.use(bodyParser.urlencoded({ extended: true })); + + +app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(loadOpenApiFile() as JsonObject)); + + +// Add Authentication for all routes except the ones listed below +app.use(authenticateToken.unless({ + path: [ + { url: '/auth/login' }, + { url: '/auth/social' }, + { url: '/auth/register' }, + { url: '/auth/refresh' }, + { url: '/auth/logout' }, + { url: /^\/post\/[^\/]+$/, methods: ['GET'] }, // Match /post/{anything} for GET + { url: /^\/comment\/[^\/]+$/, methods: ['GET'] }, // Match /comment/{anything} for GET + { url: /^\/comment\/post\/[^\/]+$/, methods: ['GET'] }, // Match /comment/post/{anything} for GET + { url: '/comment', methods: ['GET'] }, + { url: '/post', methods: ['GET'] }, // Allow GET to /post + { url: /^\/resource\/image\/[^\/]+$/, methods: ['GET'] }, // Allow GET to /resource/image/{anything} + ] +})); + +// Add AUTH middleware for params queries +// To block queries without Authentication +app.use(authenticateTokenForParams); + + + +app.use('/auth', authRoutes); +app.use('/comment', commentsRoutes); +app.use('/post', postsRoutes); +app.use("/user/:id", validateUser); +app.use('/user', usersRoutes); +app.use('/resource', resource_routes); +app.use('/room', roomsRoutes); + +export { app, corsOptions }; diff --git a/insighthub-backend/src/config/config.ts b/insighthub-backend/src/config/config.ts new file mode 100644 index 0000000..fb749a3 --- /dev/null +++ b/insighthub-backend/src/config/config.ts @@ -0,0 +1,33 @@ +export const config = { + mongo: { + uri: () => process.env.DB_CONNECTION || 'mongodb://localhost:27017' + }, + app: { + port: () => process.env.PORT || 3000, + frontend_url: () => process.env.FRONTEND_URL || 'http://localhost:5000', + backend_url: () => process.env.BACKEND_URL || `http://localhost:${config.app.port()}`, + }, + token: { + refresh_token_expiration: () => process.env.REFRESH_TOKEN_EXPIRATION || '3d', + token_expiration: () => process.env.TOKEN_EXPIRATION || '100000s', + salt: () => process.env.SALT || 10, + access_token_secret: () => process.env.ACCESS_TOKEN_SECRET || 'secret', + refresh_token_secret: () => process.env.REFRESH_TOKEN_SECRET || 'secret' + }, + resources: { + imagesDirectoryPath: () => 'resources/images', + imageMaxSize: () => 10 * 1024 * 1024 // Max file size: 10MB + }, + chatAi: { + api_url: () => process.env.CHAT_AI_API_URL || 'https://openrouter.ai/api/v1/chat/completions', + api_key: () => process.env.OPENROUTER_API_KEY || undefined, + model_name: () => process.env.OPENROUTER_MODEL_NAME || 'google/gemma-3-27b-it:free', + turned_on: () => process.env.CHAT_AI_TURNED_ON === 'true' || false + }, + socketMethods: { + messageFromServer: "message-from-server", + messageFromClient: "message-from-client", + onlineUsers: "online-users", + enterRoom: "enter-room" + } +} \ No newline at end of file diff --git a/insighthub-backend/src/config/db.ts b/insighthub-backend/src/config/db.ts new file mode 100644 index 0000000..7afe2ef --- /dev/null +++ b/insighthub-backend/src/config/db.ts @@ -0,0 +1,15 @@ +import mongoose, { ConnectOptions } from 'mongoose'; + + +export const connectToDatabase = async (connectionUri: string): Promise => { + try { + await mongoose.connect(connectionUri, { + useNewUrlParser: true, + useUnifiedTopology: true, + } as ConnectOptions); + console.log("MongoDB connected"); + } catch (err) { + console.error("MongoDB connection error:", err); + process.exit(1); + } +}; \ No newline at end of file diff --git a/insighthub-backend/src/controllers/auth_controller.ts b/insighthub-backend/src/controllers/auth_controller.ts new file mode 100644 index 0000000..e5da29b --- /dev/null +++ b/insighthub-backend/src/controllers/auth_controller.ts @@ -0,0 +1,117 @@ +import {Request, Response} from "express"; +import * as usersService from "../services/users_service"; +import {handleError} from "../utils/handle_error"; +import {CustomRequest} from "types/customRequest"; +import admin from 'firebase-admin'; +import * as dotenv from "dotenv"; + +dotenv.config(); + +// Initialize Firebase Admin SDK (ensure Firebase credentials are set in .env) +if (!admin.apps.length && process.env.FIREBASE_SERVICE_ACCOUNT) { + const serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT!); + admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), + }); +} + +export const loginUser = async (req: Request, res: Response): Promise => { + try { + const authProvider = req.body.authProvider; + const { email, password } = req.body; + const tokens = await usersService.loginUser(email, password, authProvider); + if (!tokens) { + res.status(401).json({ message: 'Invalid credentials' }); + return; + } + res.json(tokens); + } catch (err) { + handleError(err, res); + } +}; + +// Google & Facebook Authentication (using Firebase) +export const socialAuth = async (req: Request, res: Response) => { + try { + const { idToken, authProvider } = req.body; + if (!idToken) { + console.error("Missing idToken"); // Debugging line + return res.status(400).json({ message: 'Missing idToken' }); + } + if (!authProvider) { + console.error("Missing authProvider"); // Debugging line + return res.status(400).json({ message: 'Missing authProvider' }); + } + + // Verify the token using Firebase Admin SDK + const decodedToken = await admin.auth().verifyIdToken(idToken); + if (!decodedToken.email) { + console.error("Invalid token - No email found"); // Debugging line + return res.status(400).json({ message: 'Invalid token' }); + } + + const email = decodedToken.email; + const name = decodedToken.name.toString(); + const image = decodedToken.picture; + const resultTokens = await usersService.loginUserGoogle(email, authProvider, name, image); + if (!resultTokens) { + return res.status(401).json({ message: 'Invalid' }); + } + return res.status(200).json(resultTokens); + } catch (error) { + console.error("Authentication failed:", error); + return res.status(400).json({ message: "Authentication failed", error }); + } +}; + + +export const logoutUser = async (req: CustomRequest, res: Response): Promise => { + try { + const { refreshToken } = req.body; + const result = await usersService.logoutUser(refreshToken, req.user.id); + + if (!result) { + res.status(401).json({ message: 'Invalid refresh token' }); + return; + } + + res.json({ message: 'User logged out successfully' }); + } catch (err) { + handleError(err, res); + } +}; + +export const registerUser = async (req: Request, res: Response): Promise => { + try { + const { username, password, email } = req.body; + const authProvider = req.body.authProvider; + + // Check if the user already exists + const existingUser = await usersService.getUserByUsernameOrEmail(username, email); + if (existingUser) { + res.status(400).json({ message: 'Username or email already in use' }); + return; + } + + const savedUser = await usersService.registerUser(username, password, email, authProvider); + res.status(201).json(savedUser); + } catch (err) { + handleError(err, res); + } +}; + +export const refreshToken = async (req: Request, res: Response): Promise => { + try { + const { refreshToken } = req.body; + if (!refreshToken) { + res.status(401).json({ message: 'Refresh token required' }); + return; + } + + const { newRefreshToken, accessToken } = await usersService.refreshToken(refreshToken); + res.json({ accessToken: accessToken, refreshToken: newRefreshToken }); + } catch (err) { + const e: Error = err as Error + res.status(401).json({ message: e.message }); + } +}; diff --git a/insighthub-backend/src/controllers/comments_controller.ts b/insighthub-backend/src/controllers/comments_controller.ts new file mode 100644 index 0000000..88d2e92 --- /dev/null +++ b/insighthub-backend/src/controllers/comments_controller.ts @@ -0,0 +1,103 @@ +import { Request, Response } from 'express'; +import * as commentsService from '../services/comments_service'; +import * as postsService from '../services/posts_service'; +import { handleError } from '../utils/handle_error'; +import {CommentData} from "types/comment_types"; +import {CustomRequest} from "types/customRequest"; + +export const addComment = async (req: CustomRequest, res: Response): Promise => { + try { + const { postId, content } = req.body; + const owner = req.user.id; + + // Validate if the post exists + const postExists = await postsService.getPostById(postId); + if (!postExists) { + res.status(404).json({ message: "Post not found: " + postId }); + return; + } + + const commentData: CommentData = { postId, owner, content }; + const savedComment = await commentsService.addComment(commentData); + res.status(201).json(savedComment); + } catch (err) { + handleError(err, res); + } +}; + + +export const getCommentById = async (req: Request, res: Response): Promise => { + try { + const comment = await commentsService.getCommentById(req.params.commentId); + if (!comment) { + res.status(404).json({ message: "Comment not found: " + req.params.commentId }); + return; + } else { + res.json(comment); + } + } catch (err) { + handleError(err, res); + } +}; + + + +export const getCommentsByPostId = async (req: Request, res: Response): Promise => { + try { + const postExists = await postsService.getPostById(req.params.postId); + if (!postExists) { + res.status(400).json({ message: "Post not found: " + req.params.postId }); + return; + } + + const comments = await commentsService.getCommentsByPostId(req.params.postId); + if (comments.length === 0) { + res.status(200).json([]); + } else { + res.json(comments); + } + } catch (err) { + handleError(err, res); + } +}; + +export const getAllComments = async (req: Request, res: Response): Promise => { + try { + const comments = await commentsService.getAllComments(); + if (comments.length === 0) { + res.status(200).json([]); + } else { + res.json(comments); + } + } catch (err) { + handleError(err, res); + } +}; + +// TODO - make different functions for updating comment and updating comment content +export const updateComment = async (req: Request, res: Response): Promise => { + try { + // TODO - parse the body before sending to server + const updatedComment = await commentsService.updateComment(req.params.commentId, req.body); + if (!updatedComment) { + res.status(404).json({ message: 'Comment not found' }); + } else { + res.json(updatedComment); + } + } catch (err) { + handleError(err, res); + } +}; + +export const deleteComment = async (req: Request, res: Response): Promise => { + try { + const deletedComment = await commentsService.deleteComment(req.params.commentId); + if (!deletedComment) { + res.status(404).json({ message: 'Comment not found' }); + } else { + res.json({ message: 'Comment deleted successfully' }); + } + } catch (err) { + handleError(err, res); + } +}; \ No newline at end of file diff --git a/insighthub-backend/src/controllers/posts_controller.ts b/insighthub-backend/src/controllers/posts_controller.ts new file mode 100644 index 0000000..f186e6d --- /dev/null +++ b/insighthub-backend/src/controllers/posts_controller.ts @@ -0,0 +1,173 @@ +import { Request, Response } from 'express'; +import * as postsService from '../services/posts_service'; +import { handleError } from '../utils/handle_error'; +import {CustomRequest} from "types/customRequest"; +import {PostData} from "types/post_types"; +import {getLikedPostsByUser, getPostLikesCount, postExists, updatePostLike} from "../services/posts_service"; +import LikeModel from '../models/like_model'; +import mongoose from 'mongoose'; + + +export const addPost = async (req: CustomRequest, res: Response): Promise => { + try { + const postData: PostData = { + title: req.body.title, + content: req.body.content, + owner: req.user.id + }; + + const savedPost: PostData = await postsService.addPost(postData); + + res.status(201).json(savedPost); + } catch (err) { + handleError(err, res); + } +}; + +export const getPosts = async (req: Request, res: Response): Promise => { + try { + const page = parseInt(req.query.page as string) || 1; // Default to page 1 + const limit = parseInt(req.query.limit as string) || 5; // Default to 5 posts per page + const skip = (page - 1) * limit; + + let posts; + if (req.query.owner) { + posts = await postsService.getPosts(req.query.owner as string, skip, limit); + } else if (req.query.username) { + posts = await postsService.getPostsByUsername(req.query.username as string); + } else { + posts = await postsService.getPosts(undefined, skip, limit); + } + + if (posts.length === 0) { + res.status(200).json([]); + } + else { + const totalPosts = await postsService.getTotalPosts(req.query.owner as string, req.query.username as string); + const totalPages = Math.ceil(totalPosts / limit); + + res.status(200).json({ + posts, + totalPosts, + totalPages, + currentPage: page, + }); + } + } catch (err) { + handleError(err, res); + } +}; + +export const getPostById = async (req: Request, res: Response): Promise => { + try { + const post = await postsService.getPostById(req.params.postId); + if (!post) { + res.status(404).json({ message: 'Post not found' }); + } else { + res.json(post); + } + } catch (err) { + handleError(err, res); + } +}; + +// TODO - create a difference between PUT and PATCH +export const updatePost = async (req: CustomRequest, res: Response): Promise => { + try { + const owner = req.user.id; + const postId = req.params.postId; + const postData: PostData = { + title: req.body.title, + content: req.body.content, + owner + }; + + if (await postsService.postExists(postId)) { + + if (await postsService.isPostOwnedByUser(postId, owner)) { + + const updatedPost = await postsService.updatePost(postId, postData); + if (!updatedPost) { + res.status(404).json({message: 'Post not found'}); + } else { + res.status(200).json(updatedPost); + } + } else { // If someone not the owner + res.status(403).json({message: 'Forbidden'}); + } + } else { // If someone try to update post that doesn't exist + res.status(404).json({ message: 'Post not found' }); + } + } catch (err) { + handleError(err, res); + } +}; + +export const updateLikeByPostId = async (req: CustomRequest, res: Response): Promise => { + const userId = req.user.id; + const id = req.params.postId; + const booleanValue: any = req.body.value; + try { + if (booleanValue instanceof String && booleanValue != "false" && booleanValue != "true") { + res.status(400).send("Bad Request. Body accepts `true` or `false` values only"); + } + + const oldPost = await postExists(id); + if (oldPost == null) { + res.status(404).send('Post not found'); + } + + await updatePostLike(id, booleanValue, userId) + + res.status(200).send("Success"); + } catch(err) { + handleError(err, res); + } +} + +export const getLikesByPostId = async (req: Request, res: Response): Promise => { + const postId = req.params.postId; + + try { + // Fetch the count of likes and the list of users who liked the post + const likes = await LikeModel.find({ postId: new mongoose.Types.ObjectId(postId) }).select('userId').exec(); + const likesCount = likes.length; + const likedBy = likes.map((like) => like.userId.toString()); // Extract user IDs + + res.status(200).json({ count: likesCount, likedBy }); + } catch (error) { + console.error(`Error fetching likes for post ${postId}:`, error); + handleError(error, res); + } +}; + + + +export const getLikedPosts = async (req: CustomRequest, res: Response): Promise => { + try { + const userId = req.user.id; + const likedPostsByUserId = await getLikedPostsByUser(userId) + + res.status(200).send(likedPostsByUserId); + } catch(err){ + handleError(err, res); + } +} + + +export const deletePostById = async (req: Request, res: Response): Promise => { + try { + const postId = req.params.postId; + const post = await postsService.getPostById(postId); + if (!post) { + res.status(404).json({ message: 'Post not found' }); + } else { + await postsService.deletePostById(postId); + res.json({ message: 'Post and associated comments deleted successfully' }); + } + } catch (err) { + handleError(err, res); + } +}; + + diff --git a/insighthub-backend/src/controllers/resources_controller.ts b/insighthub-backend/src/controllers/resources_controller.ts new file mode 100644 index 0000000..a178a98 --- /dev/null +++ b/insighthub-backend/src/controllers/resources_controller.ts @@ -0,0 +1,52 @@ +import { Request, Response } from 'express'; +import { config } from '../config/config'; +import fs from 'fs'; +import path from 'path'; +import { uploadImage } from '../services/resources_service'; +import multer from 'multer'; +import {CustomRequest} from "types/customRequest"; +import {updateUserById} from "../services/users_service"; +import {handleError} from "../utils/handle_error"; + + +const createUserImageResource = async (req: CustomRequest, res: Response) => { + try { + const imageFilename = await uploadImage(req); + + const updatedUser = updateUserById(req.user.id, {imageFilename}); + + return res.status(201).send(updatedUser); + } catch (error) { + handleError(error, res); + } +}; + +const createImageResource = async (req: Request, res: Response) => { + try { + const imageFilename = await uploadImage(req); + return res.status(201).send(imageFilename); + } catch (error) { + if (error instanceof multer.MulterError || error instanceof TypeError) { + return res.status(400).send(error.message); + } else { + handleError(error, res); + } + } +}; + +const getImageResource = async (req: Request, res: Response) => { + try { + const { filename } = req.params; + const imagePath = path.resolve(config.resources.imagesDirectoryPath(), filename); + + if (!fs.existsSync(imagePath)) { + return res.status(404).send('Image not found'); + } + + res.sendFile(imagePath); + } catch (error) { + handleError(error, res); + } +}; + +export default { createUserImageResource, createImageResource, getImageResource }; \ No newline at end of file diff --git a/insighthub-backend/src/controllers/rooms_controller.ts b/insighthub-backend/src/controllers/rooms_controller.ts new file mode 100644 index 0000000..6e0958c --- /dev/null +++ b/insighthub-backend/src/controllers/rooms_controller.ts @@ -0,0 +1,71 @@ +import mongoose from "mongoose"; +import roomModel from "../models/room_model"; +import { CustomRequest } from "types/customRequest"; +import { Response } from "express"; + + +export const getRoomByUserIds = async (req: CustomRequest, res: Response): Promise => { + const receiverUserId = req.params.receiverUserId as string; + const initiatorUserId = req.user.id as string; + try { + let roomDocuments: any[] = await roomModel.aggregate( + [ + { + $match: { + userIds: { + $in: [new mongoose.Types.ObjectId(receiverUserId)] + } + } + }, + { + $match: { + userIds: { + $in: [new mongoose.Types.ObjectId(initiatorUserId)] + } + } + }, + { + $lookup: { + from: 'messages', + localField: '_id', + foreignField: 'roomId', + as: 'messages' + } + } + ] + ); + + // Handle that a user could send to himself + let room = null; + for (let i = 0; i < roomDocuments.length; i++) { + room = roomDocuments[i]; + if (room.userIds[0].toString() == room.userIds[1].toString()) { + break; + } + } + if (room && room.userIds[0].toString() != room.userIds[1].toString() && + receiverUserId.toString() == initiatorUserId.toString()) { + room = null; + } + + if (room) { + res.status(200).send(room); + return; + } + + // Room not found, create one. + const newRoom = await roomModel.create({ + userIds: [ + new mongoose.Types.ObjectId(receiverUserId), + new mongoose.Types.ObjectId(initiatorUserId) + ] + }); + + room = newRoom.toObject(); + (room as any).messages = []; + res.status(201).send(room); + return; + } catch(error){ + res.status(400).send("Bad Request"); + } +}; diff --git a/insighthub-backend/src/controllers/users_controller.ts b/insighthub-backend/src/controllers/users_controller.ts new file mode 100644 index 0000000..7a0a06e --- /dev/null +++ b/insighthub-backend/src/controllers/users_controller.ts @@ -0,0 +1,64 @@ +import { Request, Response } from 'express'; +import * as usersService from '../services/users_service'; +import { handleError } from '../utils/handle_error'; +import {registerUser} from "./auth_controller"; + +export const createUser = async (req: Request, res: Response): Promise => { + await registerUser(req, res); +}; + +export const getUsers = async (req: Request, res: Response): Promise => { + try { + const users = await usersService.getUsers(); + if (users.length === 0) { + res.status(204).json([]); + } else { + res.json(users); + } + } catch (err) { + handleError(err, res); + } +}; + + + +export const getUserById = async (req: Request, res: Response): Promise => { + try { + const user = await usersService.getUserById(req.params.id); + if (!user) { + res.status(404).json({ message: 'User not found' }); + return; + } + res.json(user); + } catch (err) { + handleError(err, res); + } +} + + +export const updateUserById = async (req: Request, res: Response): Promise => { + try { + const user = await usersService.updateUserById(req.params.id, req.body); + if (!user) { + res.status(404).json({ message: 'User not found' }); + return; + } + res.json(user); + } catch (err) { + handleError(err, res); + } +} + + +export const deleteUserById = async (req: Request, res: Response): Promise => { + try { + const user = await usersService.deleteUserById(req.params.id); + if (!user) { + res.status(404).json({ message: 'User not found' }); + return; + } + res.json({ message: 'User deleted successfully' }); + } catch (err) { + handleError(err, res); + } +} diff --git a/insighthub-backend/src/docs/swagger_options.ts b/insighthub-backend/src/docs/swagger_options.ts new file mode 100644 index 0000000..904a27c --- /dev/null +++ b/insighthub-backend/src/docs/swagger_options.ts @@ -0,0 +1,65 @@ + +const commentSchema = { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The comment ID' + }, + postId: { + type: 'string', + description: 'The ID of the post the comment belongs to' + }, + content: { + type: 'string', + description: 'The content of the comment' + }, + owner: { + type: 'string', + description: 'The ID of the user who owns the comment' + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'Timestamp when the comment was created' + }, + updatedAt: { + type: 'string', + format: 'date-time', + description: 'Timestamp when the comment was last updated' + } + } +}; + +const security = { + BearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT' + } + }; + +const options = { + definition: { + openapi: '3.0.0', + info: { + title: 'API Documentation', + version: '1.0.0', + }, + components: { + securitySchemes: security, + schemas: { + Comment: commentSchema + } + }, + security: [ + { + BearerAuth: [] + } + ] + }, + apis: ['./src/routes/*.ts'] +}; + +export default options; + diff --git a/insighthub-backend/src/middleware/auth.ts b/insighthub-backend/src/middleware/auth.ts new file mode 100644 index 0000000..56c8ddc --- /dev/null +++ b/insighthub-backend/src/middleware/auth.ts @@ -0,0 +1,72 @@ +import {Response, NextFunction} from 'express'; +import jwt from 'jsonwebtoken'; +import {config} from '../config/config'; +import { CustomRequest } from 'types/customRequest'; +import {unless} from 'express-unless'; +import * as usersService from '../services/users_service'; + + +const getTokenFromHeader = (req: CustomRequest): string | undefined => { + const authHeader= (req.headers['authorization'] as string | undefined) ?? (req.headers['Authorization'] as string | undefined); + return authHeader?.split(' ')[1]; +} + +const authenticateTokenHandler: any & { unless: typeof unless } = async (req: CustomRequest, res: Response, next: NextFunction, ignoreExpiration = false): Promise => { + const token = getTokenFromHeader(req); + + if (!token) { + res.status(401).json({ message: 'Access token required' }); + return; + } + + try { + const isBlacklisted = await usersService.isAccessTokenBlacklisted(token); + if (isBlacklisted) { + res.status(403).json({ message: 'Token is blacklisted' }); + return; + } + + const decoded = jwt.verify(token, config.token.access_token_secret(), { ignoreExpiration }) as jwt.JwtPayload; + const user = await usersService.getUserById(decoded.userId); + + if (!user) { + res.status(403).json({ message: 'Invalid token' }); + return; + } + + req.user = user; + next(); + + } catch (err: any) { + if (err.name === 'TokenExpiredError') { + res.status(401).json({ message: 'Token expired' }); + } else { + res.status(403).json({ message: 'Invalid token' }); + } + } +}; + + +// Middleware to authenticate token for all requests +const authenticateToken: any & { unless: typeof unless } = async (req: CustomRequest, res: Response, next: NextFunction): Promise => { + authenticateTokenHandler(req, res, next, false) +} + +authenticateToken.unless = unless; + +const authenticateTokenForParams: any & { unless: typeof unless } = async (req: CustomRequest, res: Response, next: NextFunction): Promise => { + if (Object.keys(req.query).length > 0) { + authenticateTokenHandler(req, res, next, false) + } + else { + next(); + } +} + +const authenticateLogoutToken = async (req: CustomRequest, res: Response, next: NextFunction) => { + authenticateTokenHandler(req, res, next, true) +} + +authenticateLogoutToken.unless = unless; + +export {authenticateToken, authenticateLogoutToken, authenticateTokenForParams}; diff --git a/insighthub-backend/src/middleware/socket_auth.ts b/insighthub-backend/src/middleware/socket_auth.ts new file mode 100644 index 0000000..537d1b1 --- /dev/null +++ b/insighthub-backend/src/middleware/socket_auth.ts @@ -0,0 +1,27 @@ +import { Socket } from 'socket.io'; +import jwt from 'jsonwebtoken'; +import { config } from '../config/config'; +import * as usersService from '../services/users_service'; + + +const getTokenFromHeader = (socket: Socket): string | undefined => { + const authHeader = (socket.handshake.headers['authorization'] as string | undefined) ?? (socket.handshake.headers['Authorization'] as string | undefined); + return authHeader?.split(' ')[1]; +} + +export const socketAuthMiddleware = async (socket: Socket, next: (err?: Error) => void, ignoreExpiration = false): Promise => { + const token = getTokenFromHeader(socket); + + if (!token) { + return next(new Error("401: Access token required")); + } + try { + const decoded = jwt.verify(token, config.token.access_token_secret(), { ignoreExpiration }) as jwt.JwtPayload; + const user = await usersService.getUserById(decoded.userId); + + (socket as any).user = user; // Authenticate userId + next(); + } catch (err) { + next(new Error("403: Invalid token")); + } +}; \ No newline at end of file diff --git a/insighthub-backend/src/middleware/validateUser.ts b/insighthub-backend/src/middleware/validateUser.ts new file mode 100644 index 0000000..3ca9460 --- /dev/null +++ b/insighthub-backend/src/middleware/validateUser.ts @@ -0,0 +1,25 @@ +// insighthub-backend/src/middleware/validateUser.ts +import { Response, NextFunction } from 'express'; +import {CustomRequest} from "types/customRequest"; +import {unless} from "express-unless"; + +/** + * Middleware to validate user, to perform action only on his account + * We will be able to use it, to bypass it in the future for the admin role, if we'll have one + * @param req + * @param res + * @param next + */ +const validateUser: any & { unless: typeof unless } = (req: CustomRequest, res: Response, next: NextFunction): void => { + const authenticatedUserId = req.user.id; + const userIdInParams = req.params.id; + + if (userIdInParams && authenticatedUserId !== userIdInParams) { + res.status(403).json({ message: 'Forbidden: You can only perform this action on your own account' }); + return; + } + + next(); +}; + +export default validateUser; \ No newline at end of file diff --git a/insighthub-backend/src/middleware/validation.ts b/insighthub-backend/src/middleware/validation.ts new file mode 100644 index 0000000..ce46b56 --- /dev/null +++ b/insighthub-backend/src/middleware/validation.ts @@ -0,0 +1,88 @@ +import {body, param, validationResult} from 'express-validator'; +import {Request, Response, NextFunction} from "express"; +import {handleError} from "../utils/handle_error"; + + +export const validatePostId = [ + body('postId').isMongoId().withMessage('Invalid post ID'), + ] + +export const validateComment = [ + ...validatePostId, + body('content').isString().isLength({ min: 1 }).withMessage('Content is required'), +]; + + + +export const validateCommentId = [ + param('commentId').isMongoId().withMessage('Invalid comment ID'), +]; + +export const validateCommentDataOptional = [ + ...validateCommentId, + body('content').optional().isString().isLength({ min: 1 }).withMessage('Content is required'), +]; + +export const validateCommentData = [ + ...validateCommentDataOptional, + body('content').notEmpty().withMessage('Content is required'), +]; + + +export const validateEmailPassword = [ + body('email').optional().isEmail().withMessage('Invalid email format'), + body('password').optional().isLength({ min: 6 }).withMessage('Password must be at least 6 characters long'), + ]; + +export const validateUserDataOptional = [ + body('username').optional().notEmpty().withMessage('Username is required'), + ...validateEmailPassword +]; + +export const validateUserRegister = [ + body('username').notEmpty().withMessage('Username is required'), + body('email').notEmpty().withMessage('Email is required'), + body('password').notEmpty().withMessage('Password is required'), + ...validateUserDataOptional +] ; + +export const validateUserId = [ + param('id').isMongoId().withMessage('Invalid user ID'), +]; + +export const validateLogin = [ + body('email').notEmpty().withMessage('Email is required'), + body('password').notEmpty().withMessage('Password is required'), + ...validateEmailPassword +]; + +export const validateRefreshToken = [ + body('refreshToken').notEmpty().withMessage('Refresh token is required').isString().withMessage('Refresh token must be a string'), +]; + +export const validatePostDataOptional = [ + body('title').optional().isString().isLength({ min: 1 }).withMessage('Title is required'), + body('content').optional().isString().isLength({ min: 1 }).withMessage('Content is required'), +]; + +export const validatePostData = [ + body('title').notEmpty().withMessage('Title is required'), + body('content').notEmpty().withMessage('Content is required'), + ...validatePostDataOptional +]; + +export const validatePostIdParam = [ + param('postId').isMongoId().withMessage('Invalid post ID'), +]; + +export const validateRoomUserIds = [ + param('receiverUserId').isMongoId().withMessage('Invalid user ID'), +]; + +export const handleValidationErrors = (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return handleError({ errors: errors.array(), message: 'Validation failed' }, res); + } + next(); +}; \ No newline at end of file diff --git a/insighthub-backend/src/models/Blacklisted_token_model.ts b/insighthub-backend/src/models/Blacklisted_token_model.ts new file mode 100644 index 0000000..8192760 --- /dev/null +++ b/insighthub-backend/src/models/Blacklisted_token_model.ts @@ -0,0 +1,16 @@ +import mongoose, { Document, Schema } from 'mongoose'; +import {config} from '../config/config'; + + +interface IBlacklistedToken extends Document { + token: string; + createdAt: Date; +} + +const BlacklistedTokenSchema: Schema = new Schema({ + token: { type: String, required: true }, + createdAt: { type: Date, default: Date.now, expires: config.token.token_expiration() } +}, { timestamps: true, strict: true, versionKey: false }); + +export const BlacklistedTokenModel = mongoose.model('BlacklistedToken', BlacklistedTokenSchema); + diff --git a/insighthub-backend/src/models/comments_model.ts b/insighthub-backend/src/models/comments_model.ts new file mode 100644 index 0000000..c5b2d6f --- /dev/null +++ b/insighthub-backend/src/models/comments_model.ts @@ -0,0 +1,45 @@ +import mongoose, { Document, Schema } from 'mongoose'; +import { IComment } from 'types/comment_types'; + + +const commentSchema: Schema = new mongoose.Schema({ + postId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Posts", + required: true, + }, + content: { + type: String, + required: true, + }, + owner: { + type: mongoose.Schema.Types.ObjectId, + required: true, + ref: "User" + }, + createdAt: { + type: Date, + default: Date.now + }, + updatedAt: { + type: Date, + default: Date.now + } +}, { timestamps: true, strict: true, versionKey: false }); + +commentSchema.set('toJSON', { + transform: (doc: Document, ret: Record) => { + return { + id: ret._id, + postId: ret.postId, + content: ret.content, + owner: ret.owner, + createdAt: ret.createdAt, + updatedAt: ret.updatedAt, + }; + } +}); + +export const CommentModel = mongoose.model("Comments", commentSchema); + + diff --git a/insighthub-backend/src/models/like_model.ts b/insighthub-backend/src/models/like_model.ts new file mode 100644 index 0000000..13ecd1d --- /dev/null +++ b/insighthub-backend/src/models/like_model.ts @@ -0,0 +1,22 @@ + +import mongoose from 'mongoose'; +const Schema = mongoose.Schema; + +const likeSchema = new Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Users', + required: true + }, + postId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Posts', + required: true + } +}, { + versionKey: false, +}); + +const LikeModel = mongoose.model('Likes', likeSchema); + +export default LikeModel; \ No newline at end of file diff --git a/insighthub-backend/src/models/message_model.ts b/insighthub-backend/src/models/message_model.ts new file mode 100644 index 0000000..18a32cb --- /dev/null +++ b/insighthub-backend/src/models/message_model.ts @@ -0,0 +1,27 @@ + +import mongoose from 'mongoose'; +const Schema = mongoose.Schema; + +const messageSchema = new Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Users', + required: true + }, + roomId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Rooms', + required: true + }, + createdAt: { + type: Date, + required: true + }, + content: String, +}, { + versionKey: false, +}); + +const RoomModel = mongoose.model('Messages', messageSchema); + +export default RoomModel; \ No newline at end of file diff --git a/insighthub-backend/src/models/posts_model.ts b/insighthub-backend/src/models/posts_model.ts new file mode 100644 index 0000000..ef959be --- /dev/null +++ b/insighthub-backend/src/models/posts_model.ts @@ -0,0 +1,39 @@ +import mongoose, { Document, Schema } from 'mongoose'; +import {IPost, PostData} from 'types/post_types'; + +const postSchema: Schema = new mongoose.Schema({ + title: { + type: String, + required: true, + }, + content: String, + owner: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + createdAt: { + type: Date, + default: Date.now + }, + updatedAt: { + type: Date, + default: Date.now + } +}, { timestamps: true, strict: true, versionKey: false }); + +postSchema.set('toJSON', { + transform: (doc: Document, ret: Record): PostData => { + return { + id: ret._id, + title: ret.title, + content: ret.content, + owner: ret.owner._id.toString(), + createdAt: ret.createdAt, + updatedAt: ret.updatedAt + + }; + } +}); + +export const PostModel = mongoose.model("Posts", postSchema); diff --git a/insighthub-backend/src/models/refresh_token_model.ts b/insighthub-backend/src/models/refresh_token_model.ts new file mode 100644 index 0000000..e02182e --- /dev/null +++ b/insighthub-backend/src/models/refresh_token_model.ts @@ -0,0 +1,17 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +export interface IRefreshToken extends Document { + userId: string; + token: string; + accessToken: string; + createdAt: Date; +} + +const RefreshTokenSchema: Schema = new Schema({ + userId: { type: String, required: true }, + token: { type: String, required: true }, + accessToken: { type: String, required: true }, + createdAt: { type: Date, default: Date.now, expires: '7d' }, // Token expires in 7 days +}, { timestamps: true, strict: true, versionKey: false }); + +export const RefreshTokenModel = mongoose.model('RefreshToken', RefreshTokenSchema); \ No newline at end of file diff --git a/insighthub-backend/src/models/room_model.ts b/insighthub-backend/src/models/room_model.ts new file mode 100644 index 0000000..a6e9165 --- /dev/null +++ b/insighthub-backend/src/models/room_model.ts @@ -0,0 +1,17 @@ + +import mongoose from 'mongoose'; +const Schema = mongoose.Schema; + +const roomSchema = new Schema({ + userIds: { + type: [mongoose.Schema.Types.ObjectId], + ref: 'Users', + required: true + }, +}, { + versionKey: false, +}); + +const RoomModel = mongoose.model('Rooms', roomSchema); + +export default RoomModel; \ No newline at end of file diff --git a/insighthub-backend/src/models/user_model.ts b/insighthub-backend/src/models/user_model.ts new file mode 100644 index 0000000..62c232e --- /dev/null +++ b/insighthub-backend/src/models/user_model.ts @@ -0,0 +1,45 @@ +import mongoose, { Schema } from 'mongoose'; +import { IUser , UserData} from 'types/user_types'; + +const userSchema: Schema = new Schema({ + username: { + type: String, + }, + email: { + type: String, + required: true + }, + password: { + type: String, + }, // Store hashed passwords + imageFilename: { + type: String + }, + createdAt: { + type: Date, + default: Date.now + }, + updatedAt: { + type: Date, + default: Date.now + }, + authProvider: { + type: String + } +}, { timestamps: true, strict: true, versionKey: false }); + +userSchema.set('toJSON', { + transform: (doc, ret): UserData => { + return { + id: ret._id, + username: ret.username, + email: ret.email, + password: ret.password, + imageFilename: ret?.imageFilename, + createdAt: ret.createdAt, + updatedAt: ret.updatedAt + }; + } +}); + +export const UserModel = mongoose.model('User', userSchema); \ No newline at end of file diff --git a/insighthub-backend/src/openapi/openapi_loader.ts b/insighthub-backend/src/openapi/openapi_loader.ts new file mode 100644 index 0000000..313b07e --- /dev/null +++ b/insighthub-backend/src/openapi/openapi_loader.ts @@ -0,0 +1,16 @@ +import yaml from 'js-yaml'; +import fs from 'fs'; +import path from 'path'; + +const loadOpenApiFile = () => { + try { + const swaggerPath = path.join(__dirname, 'swagger.yaml'); + const swaggerContent = fs.readFileSync(swaggerPath, 'utf8'); + return yaml.load(swaggerContent); + } catch (error) { + console.error('Error loading OpenAPI file:', error); + return error; + } +}; + +export default loadOpenApiFile; \ No newline at end of file diff --git a/insighthub-backend/src/openapi/swagger.yaml b/insighthub-backend/src/openapi/swagger.yaml new file mode 100644 index 0000000..db75286 --- /dev/null +++ b/insighthub-backend/src/openapi/swagger.yaml @@ -0,0 +1,1006 @@ +openapi: 3.0.1 +info: + title: InsightHub API + description: API for managing all the abilities. + version: 1.0.0 + +tags: + - name: Posts + description: Operations related to posts + - name: Comments + description: Operations related to comments on posts + - name: Authentication + description: Operations related to authentication tokens + - name: Users + description: Operations related to users + - name: Resources + description: Operations related to uploading & downloading resources + - name: Rooms + description: Operations related to chat rooms + +paths: + /post: + get: + tags: + - Posts + summary: Retrieve all posts + parameters: + - in: query + name: owner + schema: + type: string + description: Filter posts by owner (requires authentication if provided) + responses: + '200': + description: List of posts retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Post' + '401': + description: Unauthorized - Missing token -> when owner is provided + '403': + description: Forbidden - Invalid or expired token -> when owner is provided + + post: + tags: + - Posts + security: + - BearerAuth: [] + summary: Create a new post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PostRequest' + responses: + '201': + description: Post created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /post/{postId}: + get: + tags: + - Posts + summary: Retrieve all posts + parameters: + - name: postId + in: path + required: true + schema: + type: string + responses: + '200': + description: List of posts retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Post' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/PostNotFound' + put: + tags: + - Posts + security: + - BearerAuth: [] + summary: Update a post entirely - will replace the existing post + parameters: + - name: postId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PostRequest' + responses: + '201': + description: Post updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/PostNotFound' + + patch: + tags: + - Posts + security: + - BearerAuth: [ ] + summary: Update a post + parameters: + - name: postId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PostRequestPartial' + responses: + '201': + description: Post updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/PostNotFound' + + delete: + tags: + - Posts + security: + - BearerAuth: [ ] + summary: Delete a post + parameters: + - name: postId + in: path + required: true + schema: + type: string + responses: + '200': + description: Post deleted successfully + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/PostNotFound' + + /post/{postId}/like: + put: + tags: + - Posts + security: + - BearerAuth: [] + summary: Update like status for a specific post + parameters: + - name: postId + in: path + required: true + schema: + type: string + description: The ID of the post to like or unlike + requestBody: + required: true + content: + text/plain: + schema: + type: boolean + description: Indicates whether to like (true) or unlike (false) the post + responses: + '200': + description: Like status updated successfully + '400': + description: Bad request - Invalid post ID or missing required fields + content: + text/plain: + examples: + default: + value: "Bad Request" + booleanBody: + value: "Bad Request. Body accepts `true` or `false` values only" + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/PostNotFound' + + /post/like: + get: + tags: + - Posts + security: + - BearerAuth: [] + summary: Retrieve liked posts for a specific user + responses: + '200': + description: A list of liked posts for the specified user + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Post' + '400': + description: Bad request - Invalid userId + '401': + description: Unauthorized - Missing token + + # Comment Routes + /comment: + get: + tags: + - Comments + summary: Retrieve all comments + responses: + '200': + description: List of comments retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Comment' + + post: + tags: + - Comments + security: + - BearerAuth: [] + summary: Create a new comment + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CommentRequest' + responses: + '201': + description: Comment created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/PostNotFound' + + /comment/{commentId}: + put: + tags: + - Comments + security: + - BearerAuth: [] + summary: Update a comment + parameters: + - name: commentId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CommentRequest' + responses: + '200': + description: Comment updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/CommentNotFound' + + patch: + tags: + - Comments + security: + - BearerAuth: [ ] + summary: Update a comment + parameters: + - name: commentId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CommentRequestPartial' + responses: + '200': + description: Comment updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/CommentNotFound' + + delete: + tags: + - Comments + security: + - BearerAuth: [] + summary: Delete a comment + parameters: + - name: commentId + in: path + required: true + schema: + type: string + responses: + '200': + description: Comment deleted successfully + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/CommentNotFound' + + /comment/post/{postId}: + get: + tags: + - Comments + summary: Get comments for a specific post + parameters: + - name: postId + in: path + required: true + schema: + type: string + responses: + '200': + description: List of comments for the post + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Comment' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/PostNotFound' + + # Authentication Routes + /auth/register: + post: + tags: + - Authentication + summary: Register a new user + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterRequest' + responses: + '201': + description: User successfully registered + '400': + $ref: '#/components/responses/BadRequest' + + /auth/login: + post: + tags: + - Authentication + summary: Login user + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: Successfully logged in + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '401': + description: Invalid credentials + + /auth/refresh: + post: + tags: + - Authentication + summary: Refresh access token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RefreshTokenRequest' + responses: + '200': + description: New tokens generated + content: + application/json: + schema: + type: object + properties: + accessToken: + type: string + refreshToken: + type: string + '401': + description: Invalid refresh token + + /auth/logout: + post: + tags: + - Authentication + security: + - BearerAuth: [ ] + summary: Logout user + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - refreshToken + properties: + refreshToken: + type: string + responses: + '200': + description: Successfully logged out + '401': + description: Invalid refresh token + + /user: + get: + tags: + - Users + security: + - BearerAuth: [ ] + summary: Retrieve all users + responses: + '200': + description: List of users retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + /user/{userId}: + get: + tags: + - Users + security: + - BearerAuth: [ ] + summary: Retrieve a user by ID + parameters: + - name: userId + in: path + required: true + schema: + type: string + responses: + '200': + description: User retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/UserNotFound' + + patch: + tags: + - Users + security: + - BearerAuth: [ ] + summary: Update a user + parameters: + - name: userId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EditUserRequest' + responses: + '200': + description: User updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/UserNotFound' + + delete: + tags: + - Users + security: + - BearerAuth: [ ] + summary: Delete a user + parameters: + - name: userId + in: path + required: true + schema: + type: string + responses: + '200': + description: User deleted successfully + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/UserNotFound' + + + /resource/image/user: + post: + tags: + - Resources + summary: Upload an image for a user + security: + - BearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '201': + description: Image uploaded successfully and associated with the user + content: + application/json: + schema: + type: object + properties: + _id: + type: string + description: The unique identifier of the user + email: + type: string + description: The email of the user + imageFilename: + type: string + description: The filename of the uploaded image + accessToken: + type: string + refreshToken: + type: string + '400': + description: Bad request + content: + text/plain: + examples: + fileTooLarge: + value: "File too large" + invalidFileType: + value: "Invalid file type. Only images are allowed: /jpeg|jpg|png|gif/" + noFileUploaded: + value: "No file uploaded" + '404': + description: User not found + '500': + description: Internal server error + + /resource/image: + post: + tags: + - Resources + summary: Upload an image + security: + - BearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '201': + description: Image uploaded successfully + content: + text/plain: + schema: + description: The name of the uploaded file + type: string + '400': + description: Bad request + content: + text/plain: + examples: + fileTooLarge: + value: "File too large" + invalidFileType: + value: "Invalid file type. Only images are allowed: /jpeg|jpg|png|gif/" + noFileUploaded: + value: "No file uploaded" + '500': + description: Internal server error + + /resource/image/{filename}: + get: + tags: + - Resources + summary: Retrieve an image + security: + - BearerAuth: [] + parameters: + - name: filename + in: path + required: true + schema: + type: string + description: The name of the image file to retrieve + responses: + '200': + description: Image retrieved successfully + content: + image/*: + schema: + type: string + format: binary + '404': + description: Image not found + '500': + description: Internal server error + + /room/user/{receiverUserId}: + get: + tags: + - Rooms + security: + - BearerAuth: [] + summary: Get or create a room by user IDs + parameters: + - name: receiverUserId + in: path + required: true + description: The ID of the receiver user + schema: + type: string + responses: + '200': + description: Room found and returned successfully + content: + application/json: + schema: + type: object + properties: + _id: + type: string + example: "67d5d49a9757556bd7e30939" + userIds: + type: array + items: + type: string + example: ["67afa72968f736f112ae1d4f", "67afa589118b00ef7c04bbee"] + messages: + type: array + items: + type: string + example: [] + '201': + description: Room created successfully + content: + application/json: + schema: + type: object + properties: + _id: + type: string + example: "67d5d49a9757556bd7e30939" + userIds: + type: array + items: + type: string + example: ["67afa72968f736f112ae1d4f", "67afa589118b00ef7c04bbee"] + messages: + type: array + items: + type: string + example: [] + '400': + description: Bad Request + +components: + schemas: + Post: + type: object + required: + - id + - owner + - title + - content + - createdAt + - updatedAt + properties: + id: + type: string + description: The post ID + title: + type: string + description: The title of the post + content: + type: string + description: The content of the post + owner: + type: string + description: The ID of the user who owns the post + createdAt: + type: string + format: date-time + description: Timestamp when the post was created + updatedAt: + type: string + format: date-time + description: Timestamp when the post was last updated + + PostRequestPartial: + type: object + required: [] + properties: + title: + type: string + description: The title of the post + content: + type: string + description: The content of the post + + PostRequest: + allOf: + - $ref: '#/components/schemas/PostRequestPartial' + - type: object + required: + - title + - content + + Comment: + type: object + required: + - id + - postId + - owner + - content + - createdAt + - updatedAt + properties: + id: + type: string + description: The unique identifier of the comment + postId: + type: string + description: The ID of the post the comment belongs to + owner: + type: string + description: The owner of the comment + content: + type: string + description: The content of the comment + createdAt: + type: string + format: date-time + description: The timestamp when the comment was created + updatedAt: + type: string + format: date-time + description: The timestamp when the comment was last updated + + CommentRequestPartial: + type: object + required: [] + properties: + postId: + type: string + description: The ID of the post the comment should belong to + content: + type: string + description: The content of the comment + + CommentRequest: + allOf: + - $ref: '#/components/schemas/CommentRequestPartial' + - type: object + required: + - postId + - content + + RefreshResponse: + type: object + properties: + accessToken: + type: string + refreshToken: + type: string + + LoginResponse: + allOf: + - $ref: '#/components/schemas/RefreshResponse' + - type: object + required: + - userId + properties: + userId: + type: string + + + LoginRequestPartial: + type: object + required: [ ] + properties: + email: + type: string + format: email + password: + type: string + + LoginRequest: + allOf: + - $ref: '#/components/schemas/LoginRequestPartial' + - type: object + required: + - email + - password + + RegisterRequestPartial: + allOf: + - type: object + required: [] + properties: + username: + type: string + - $ref: '#/components/schemas/LoginRequestPartial' + + RegisterRequest: + allOf: + - $ref: '#/components/schemas/RegisterRequestPartial' + - type: object + required: + - email + - username + - -password + + EditUserRequest: + allOf: + - $ref: '#/components/schemas/RegisterRequestPartial' + - type: object + required: [ ] + + + User: + type: object + required: + - id + - email + - username + - createdAt + - updatedAt + properties: + id: + type: string + description: The user ID + email: + type: string + format: email + description: The email of the user + username: + type: string + description: The username of the user + createdAt: + type: string + format: date-time + description: Timestamp when the user was created + updatedAt: + type: string + format: date-time + description: Timestamp when the user was last updated + + RefreshTokenRequest: + type: object + required: + - refreshToken + properties: + refreshToken: + type: string + + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT token obtained from /auth/login endpoint + + responses: + BadRequest: + description: Bad request - Missing or bad required fields + Unauthorized: + description: Unauthorized - Missing token + Forbidden: + description: Forbidden - Invalid or expired token + PostNotFound: + description: Post not found + CommentNotFound: + description: Comment not found + UserNotFound: + description: User not found \ No newline at end of file diff --git a/insighthub-backend/src/request.rest b/insighthub-backend/src/request.rest new file mode 100644 index 0000000..4c73c21 --- /dev/null +++ b/insighthub-backend/src/request.rest @@ -0,0 +1,112 @@ + +### + +GET http://localhost:3000/post + +// GET ALL POSTS +### + +GET http://localhost:3000/post?sender=Mevorah + +//GET ALL POSTS OF SPECISIC USERNAME + +### + +GET http://localhost:3000/post/6755df72fa7704d85baec0c2 + +//GET POST BY ID + +### + +### - REGISTER +POST http://localhost:3000/auth/register +Authorization: jwt +Content-Type: application/json + +{ + "email": "berrebi@gmail.com", + "password": "0105896" +} + +### + +### - LOGIN +POST http://localhost:3000/auth/login +Content-Type: application/json + +{ + "email": "berrebi@gmail.com", + "password": "0105896" +} + +### + +### - POST NEW POST +POST http://localhost:3000/post +Content-Type: application/json +Authorization: jwt eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2Nzg2NDkxYTc5NDM3NDBiZGFmYmE5MjgiLCJyYW5kb20iOjk2NjI5OCwiaWF0IjoxNzM2ODUzODkwLCJleHAiOjE3Mzc0NTg2OTB9.oEDZaquYp4e8nAdSkeFrtIXEMh91smGsIGKo4CNiR3g + +{ + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2Nzg2NDkxYTc5NDM3NDBiZGFmYmE5MjgiLCJyYW5kb20iOjk2NjI5OCwiaWF0IjoxNzM2ODUzODkwLCJleHAiOjE3MzY4NTM4OTN9.QFrAgLp3q8XpDfVBGbeg2GD-L8p85tV7nhl70WQVp-0", + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2Nzg2NDkxYTc5NDM3NDBiZGFmYmE5MjgiLCJyYW5kb20iOjk2NjI5OCwiaWF0IjoxNzM2ODUzODkwLCJleHAiOjE3Mzc0NTg2OTB9.oEDZaquYp4e8nAdSkeFrtIXEMh91smGsIGKo4CNiR3g", + "sender": "6784e4b9878c5166ee883fde", + "title": "Post New After manuel login", + "content": "HELLO PostTEST!!" +} + +### + +PUT http://localhost:3000/post/6755da8614e9b89cb0e24bd4 + +//UPDATE POST +Content-Type: application/json + +{ + "sender": "Mevorah 1234", + "title": "Post 1234", + "content": "HEY 1234!!" +} + +### + +POST http://localhost:3000/comment +//POST NEW COMMENT +Content-Type: application/json + +{ + "postId": "6755fd5616f2c59bcec78e17", + "sender": "Mevorah", + "content": "First Comment!!" +} + +### + +PUT http://localhost:3000/comment/6755da8614e9b89cb0e24bd4 + +//UPDATE COMMENT BY COMMENT ID +Content-Type: application/json + +{ + "sender": "Tal 1234", + "content": "HELLO COMMENT 1234!!" +} + +### + +GET http://localhost:3000/comment/post/6755fd5616f2c59bcec78e17 + +//GET ALL COMMENTS OF SPECIFIC POST + +### + +DELETE http://localhost:3000/comment/6755fe0f16f2c59bcec78e27 + +//DELETE COMMENT BY COMMENT_ID + +### + +GET http://localhost:3000/comment + +//GET ALL COMMENTS + +### \ No newline at end of file diff --git a/insighthub-backend/src/routes/auth_routes.ts b/insighthub-backend/src/routes/auth_routes.ts new file mode 100644 index 0000000..8ac68d3 --- /dev/null +++ b/insighthub-backend/src/routes/auth_routes.ts @@ -0,0 +1,25 @@ +import express, { Request, Response, Router } from 'express'; +import * as authController from '../controllers/auth_controller'; +import { + handleValidationErrors, + validateLogin, + validateRefreshToken, + validateUserRegister, +} from "../middleware/validation"; +import {authenticateLogoutToken} from "../middleware/auth"; +import {CustomRequest} from "types/customRequest"; +import { socialAuth } from '../controllers/auth_controller'; + +const router: Router = express.Router(); + +router.post('/login', validateLogin, handleValidationErrors, (req: Request, res: Response) => authController.loginUser(req, res)); + +router.post('/logout', authenticateLogoutToken as unknown as express.RequestHandler, (req: Request, res: Response) => authController.logoutUser(req as CustomRequest, res)); + +router.post('/register', validateUserRegister, handleValidationErrors, (req: Request, res: Response) => authController.registerUser(req, res)); + +router.post('/refresh', validateRefreshToken, handleValidationErrors, (req: Request, res: Response) => authController.refreshToken(req, res)); + +router.post('/social', handleValidationErrors, socialAuth); + +export default router; \ No newline at end of file diff --git a/insighthub-backend/src/routes/comments_routes.ts b/insighthub-backend/src/routes/comments_routes.ts new file mode 100644 index 0000000..5058297 --- /dev/null +++ b/insighthub-backend/src/routes/comments_routes.ts @@ -0,0 +1,30 @@ +import express, { Request, Response, Router } from 'express'; +import * as commentsController from '../controllers/comments_controller'; +import { + handleValidationErrors, + validateComment, + validateCommentData, + validateCommentDataOptional, + validateCommentId, + validatePostIdParam, +} from '../middleware/validation'; +import {CustomRequest} from "types/customRequest"; + +const router: Router = express.Router(); + + +router.post('/', validateComment, handleValidationErrors, (req: Request, res: Response) => commentsController.addComment(req as CustomRequest, res)); + +router.get('/', (req: Request, res: Response) => commentsController.getAllComments(req, res)); + +router.get('/post/:postId', validatePostIdParam, handleValidationErrors, (req: Request, res: Response) => commentsController.getCommentsByPostId(req, res)); + +router.get('/:commentId', validateCommentId, handleValidationErrors, (req: Request, res: Response) => commentsController.getCommentById(req, res)); + +router.put('/:commentId', validateCommentData, handleValidationErrors, (req: Request, res: Response) => commentsController.updateComment(req, res)); + +router.patch('/:commentId', validateCommentDataOptional, handleValidationErrors, (req: Request, res: Response) => commentsController.updateComment(req, res)); + +router.delete('/:commentId', validateCommentId, handleValidationErrors, (req: Request, res: Response) => commentsController.deleteComment(req, res)); + +export default router; \ No newline at end of file diff --git a/insighthub-backend/src/routes/posts_routes.ts b/insighthub-backend/src/routes/posts_routes.ts new file mode 100644 index 0000000..384cfa3 --- /dev/null +++ b/insighthub-backend/src/routes/posts_routes.ts @@ -0,0 +1,33 @@ +import express, { Request, Response, Router } from 'express'; +import * as postsController from '../controllers/posts_controller'; +import {CustomRequest} from "types/customRequest"; +import { + handleValidationErrors, + validatePostData, + validatePostDataOptional, + validatePostIdParam +} from "../middleware/validation"; +const router: Router = express.Router(); + + +router.post('/', validatePostData, handleValidationErrors, (req: Request, res: Response) => postsController.addPost(req as CustomRequest, res)); + +router.get('/', (req: Request, res: Response) => postsController.getPosts(req, res)); + +router.get('/:postId', validatePostIdParam, handleValidationErrors,(req: Request, res: Response) => postsController.getPostById(req, res)); + +router.get('/like', (req: Request, res: Response) => postsController.getLikedPosts(req as CustomRequest, res)); + +router.put('/:postId', validatePostIdParam, validatePostData, handleValidationErrors, (req: Request, res: Response) => postsController.updatePost(req as CustomRequest, res)); + +// todo - swagger and stuff +router.get('/:postId/like', validatePostIdParam, handleValidationErrors, (req: Request, res: Response) => postsController.getLikesByPostId(req, res)); + +router.patch('/:postId', validatePostIdParam, validatePostDataOptional, handleValidationErrors, (req: Request, res: Response) => postsController.updatePost(req as CustomRequest, res)); + +router.delete('/:postId', validatePostIdParam, handleValidationErrors, (req: Request, res: Response) => postsController.deletePostById(req as CustomRequest, res)); + + +router.put('/:postId/like', express.text(), (req: Request, res: Response) => postsController.updateLikeByPostId(req as CustomRequest, res)); + +export default router; \ No newline at end of file diff --git a/insighthub-backend/src/routes/resources_routes.ts b/insighthub-backend/src/routes/resources_routes.ts new file mode 100644 index 0000000..4a24532 --- /dev/null +++ b/insighthub-backend/src/routes/resources_routes.ts @@ -0,0 +1,14 @@ + +import express, {Request, Response} from 'express'; +import Resource from '../controllers/resources_controller'; +import {CustomRequest} from "types/customRequest"; + +const router = express.Router(); + +router.post('/image/user', (req: Request, res: Response) => Resource.createUserImageResource(req as CustomRequest, res)); + +router.post('/image', Resource.createImageResource); + +router.get('/image/:filename', Resource.getImageResource); + +export default router; \ No newline at end of file diff --git a/insighthub-backend/src/routes/rooms_routes.ts b/insighthub-backend/src/routes/rooms_routes.ts new file mode 100644 index 0000000..7805a99 --- /dev/null +++ b/insighthub-backend/src/routes/rooms_routes.ts @@ -0,0 +1,11 @@ + +import express, {Request, Response} from 'express'; +import * as roomsController from '../controllers/rooms_controller'; +import { CustomRequest } from 'types/customRequest'; +import {handleValidationErrors, validateRoomUserIds} from "../middleware/validation"; + +const router = express.Router(); + +router.get('/user/:receiverUserId', validateRoomUserIds, handleValidationErrors, (req: Request, res: Response) => roomsController.getRoomByUserIds(req as CustomRequest, res)); + +export default router; \ No newline at end of file diff --git a/insighthub-backend/src/routes/users_routes.ts b/insighthub-backend/src/routes/users_routes.ts new file mode 100644 index 0000000..b781d4c --- /dev/null +++ b/insighthub-backend/src/routes/users_routes.ts @@ -0,0 +1,19 @@ +import express, { Request, Response, Router } from 'express'; +import * as usersController from '../controllers/users_controller'; +import {handleValidationErrors, validateUserDataOptional, validateUserId} from "../middleware/validation"; + +const router: Router = express.Router(); + + +router.get('/', (req: Request, res: Response) => usersController.getUsers(req, res)); + +router.get('/:id', validateUserId, handleValidationErrors, (req: Request, res: Response) => usersController.getUserById(req, res)); + +router.delete('/:id', validateUserId, handleValidationErrors, (req: Request, res: Response) => usersController.deleteUserById(req, res)); + +router.patch('/:id', validateUserId, validateUserDataOptional, handleValidationErrors, (req: Request, res: Response) => usersController.updateUserById(req, res)); + +// router.post('/', validateUserRegister, handleValidationErrors, (req: Request, res: Response) => usersController.createUser(req, res)); + + +export default router; \ No newline at end of file diff --git a/insighthub-backend/src/server.ts b/insighthub-backend/src/server.ts new file mode 100644 index 0000000..d992561 --- /dev/null +++ b/insighthub-backend/src/server.ts @@ -0,0 +1,25 @@ +import { app, corsOptions } from './app'; +import mongoose from 'mongoose'; +import { config } from './config/config'; +import dotenv from "dotenv"; +import dotenvExpand from "dotenv-expand"; +import { Server } from 'socket.io'; +import { socketAuthMiddleware } from './middleware/socket_auth'; +import { initSocket } from './services/socket_service'; +// Configure environment variables, and allow expand. +dotenvExpand.expand(dotenv.config()); + +// Start app while verifying connection to the database. +const port = config.app.port(); +const listener = app.listen(port, () => { + mongoose.connect(config.mongo.uri()) + const db = mongoose.connection; + db.on('error', (error) => console.error(error)); + db.once('open', () => console.log("Connected to DataBase")); + console.log(`Example app listening at http://localhost:${port}`); +}); + +const socketListener = new Server(listener, { cors: corsOptions }); + +socketListener.use((socket, next) => socketAuthMiddleware(socket, next)); +initSocket(socketListener).then(); diff --git a/insighthub-backend/src/services/__mocks__/chat_api_service.ts b/insighthub-backend/src/services/__mocks__/chat_api_service.ts new file mode 100644 index 0000000..9973577 --- /dev/null +++ b/insighthub-backend/src/services/__mocks__/chat_api_service.ts @@ -0,0 +1 @@ +export const chatWithAI = jest.fn().mockResolvedValue("Mocked response"); \ No newline at end of file diff --git a/insighthub-backend/src/services/chat_api_service.ts b/insighthub-backend/src/services/chat_api_service.ts new file mode 100644 index 0000000..fc5edb3 --- /dev/null +++ b/insighthub-backend/src/services/chat_api_service.ts @@ -0,0 +1,48 @@ +import axios from "axios"; +import {config} from "../config/config"; + +const cleanResponse = (response: string): string => { + return response.replace(/\\boxed{(.*?)}/g, "$1"); // Removes \boxed{} +} + + + +export const chatWithAI = async (inputUserMessage: string)=> { + try { + const API_URL = config.chatAi.api_url(); + const API_KEY = config.chatAi.api_key(); + const MODEL_NAME = config.chatAi.model_name(); + + const systemMessage = { + role: 'system', + content: 'You are an AI assistant tasked with providing the first comment on forum posts. Your responses should be relevant, engaging, and encourage further discussion, also must be short, and you must answer if you know the answer. Ensure your comments are appropriate for the content and tone of the post. Also must answer in the language of the user post. answer short answers. dont ask questions to follow up' + }; + + const userMessage = { + role: 'user', + content: inputUserMessage + }; + + + const response = await axios.post( + API_URL, + { + model: MODEL_NAME, + messages: [ systemMessage, userMessage ], + }, + { + headers: { + Authorization: `Bearer ${API_KEY}`, + "Content-Type": "application/json", + }, + } + ); + + const answer = response.data.choices[0].message.content; + const cleanedAnswer = cleanResponse(answer); + return cleanedAnswer; + } catch (error) { + console.error("Error communicating with OpenRouter:", error); + return "Sorry, an error occurred."; + } +} diff --git a/insighthub-backend/src/services/comments_service.ts b/insighthub-backend/src/services/comments_service.ts new file mode 100644 index 0000000..3e2326f --- /dev/null +++ b/insighthub-backend/src/services/comments_service.ts @@ -0,0 +1,51 @@ +import { CommentModel } from '../models/comments_model'; +import { IComment, CommentData } from 'types/comment_types'; +import { Document } from 'mongoose'; +import { UserModel } from '../models/user_model'; + +const commentToCommentData = async (comment: Document & IComment): Promise => { + const user = await UserModel.findById(comment.owner).select('imageFilename').exec(); + return { ...comment.toJSON(), owner: comment.owner.toString(), postId: comment.postId.toString(), ownerProfileImage: user?.imageFilename }; +}; + +export const addComment = async (commentData: CommentData): Promise => { + const comment = new CommentModel(commentData); + await comment.save(); + return commentToCommentData(comment); +}; + +export const getCommentsWithAuthorsByPostId = async (postId: string): Promise => { + const comments = await CommentModel.find({ postId }) + .populate('author', 'username email') + .exec(); + return Promise.all(comments.map(commentToCommentData)); +}; + +export const getCommentsByPostId = async (postId: string): Promise => { + const comments = await CommentModel.find({ postId }).exec(); + return Promise.all(comments.map(commentToCommentData)); +}; + +export const getAllComments = async (): Promise => { + const comments = await CommentModel.find().exec(); + return Promise.all(comments.map(commentToCommentData)); +}; + +export const updateComment = async (commentId: string, commentData: Partial): Promise => { + const comment = await CommentModel.findByIdAndUpdate(commentId, {content: commentData?.content}, { new: true }).exec(); + return comment ? commentToCommentData(comment) : null; +}; + +export const deleteComment = async (commentId: string): Promise => { + const comment = await CommentModel.findByIdAndDelete(commentId).exec(); + return comment ? commentToCommentData(comment) : null; +}; + +export const getCommentById = async (commentId: string): Promise => { + const comment = await CommentModel.findById(commentId).exec(); + return comment ? commentToCommentData(comment) : null; +}; + +export const deleteCommentsByPostId = async (postId: string): Promise => { + await CommentModel.deleteMany({ postId }).exec(); +}; \ No newline at end of file diff --git a/insighthub-backend/src/services/posts_service.ts b/insighthub-backend/src/services/posts_service.ts new file mode 100644 index 0000000..65ac591 --- /dev/null +++ b/insighthub-backend/src/services/posts_service.ts @@ -0,0 +1,222 @@ +import {PostModel } from '../models/posts_model'; +import { IPost, PostData } from 'types/post_types'; +import {ClientSession, Document} from 'mongoose'; +import * as mongoose from 'mongoose'; +import * as commentsService from './comments_service'; +import * as usersService from './users_service'; +import {UserModel} from "../models/user_model"; +import likeModel from "../models/like_model"; +import {CommentData} from "types/comment_types"; +import {UserData} from 'types/user_types'; +import * as chatService from './chat_api_service'; +import {config} from "../config/config"; + + +const postToPostData = async (post: Document & IPost): Promise => { + // Fetch the owner's profile image + const user = await UserModel.findById(post.owner).lean(); + const profileImage = user?.imageFilename + + return { + ...post.toJSON(), + owner: post.owner.toString(), + ownerProfileImage: profileImage, // Add the profile image to the post data + ownerUsername: user?.username + }; +} + + +const addMisterAIComment = async (postId: string, postContent: string) => { + let misterAI: UserData | null = await usersService.getUserByEmail('misterai@example.com'); + if (!misterAI) { + misterAI = await usersService.addUser('misterai', 'securepassword', 'misterai@example.com', 'local'); + } + + const comment = await chatService.chatWithAI(postContent); + const commentData: CommentData = { postId, owner: misterAI.id, content: comment }; + const savedComment = await commentsService.addComment(commentData); + return savedComment; +}; + + + +/*** + * Add a new post + * @param postData - The post data to be added + * @returns The added post + */ +export const addPost = async (postData: PostData): Promise => { + const newPost = new PostModel(postData); + await newPost.save(); + const turn = config.chatAi.turned_on(); + if(postData.content && turn) { + addMisterAIComment(newPost.id, postData.content); + } + return postToPostData(newPost); +}; + +/*** + * Get all posts + * @param owner - The owner of the posts to be fetched + * @returns The list of posts + */ +export const getPosts = async (owner?: string, skip = 0, limit = 10): Promise => { + const query = owner ? { owner } : {}; + const posts = await PostModel.find(query).skip(skip).limit(limit).exec(); + return Promise.all(posts.map(postToPostData)); +}; + +export const getTotalPosts = async (owner?: string, username?: string): Promise => { + if (username) { + const user = await UserModel.findOne({ username }).select('_id').lean().exec(); + if (!user) return 0; + return PostModel.countDocuments({ owner: user._id }).exec(); + } + const query = owner ? { owner } : {}; + return PostModel.countDocuments(query).exec(); +}; + +/*** + * Get posts by username + * @param username - The username of the posts to be fetched + * @returns The list of posts + */ +export const getPostsByUsername = async (username: string): Promise => { + if (!username) return []; + + // Find the user first + const user = await UserModel.findOne({ username }).select('_id').lean().exec(); + if (!user) return []; + + // Then find posts with that user's ID + const posts = await PostModel.find({ owner: user._id }).exec(); + return Promise.all(posts.map(postToPostData)); +}; + +/** + * Get a post by ID + * @param postId + */ +export const getPostById = async (postId: string): Promise => { + const post = await PostModel.findById(postId).exec(); + return post ? postToPostData(post) : null; +}; + + +/** + * Delete a post by ID + * @param postId + */ +export const deletePostById = async (postId: string): Promise => { + try { + // Delete comments associated with the post + await commentsService.deleteCommentsByPostId(postId); + + // Delete the post + const post = await PostModel.findByIdAndDelete(postId).exec(); + + // Return the deleted post data + return post ? await postToPostData(post) : null; + } catch (error) { + console.error("Error deleting post:", error); + throw error; + } +}; + + +/** + * Update a post + * @param postId + * @param postData + */ +export const updatePost = async (postId: string, postData: Partial): Promise => { + const updatedPost = await PostModel.findByIdAndUpdate(postId, { ...postData }, { new: true, runValidators: true }).exec(); + return updatedPost ? postToPostData(updatedPost) : null; +}; + +/** + * Check if a post is owned by a specific user + * @param postId + * @param ownerId + * @returns boolean indicating ownership + */ +export const isPostOwnedByUser = async (postId: string, ownerId: string): Promise => { + const post = await PostModel.findById(postId).exec(); + return post ? post.owner.toString() === ownerId : false; +}; + + +/** + * Check if a post exists by ID + * @param postId + * @returns boolean indicating existence + */ +export const postExists = async (postId: string): Promise => { + const post = await PostModel.exists({ _id: postId }).exec(); + return post !== null; +}; + +export const updatePostLike = async (postId: string, booleanValue: string, userId: string): Promise => { + const post = await getPostById(postId); + if(post != null) { + if (booleanValue) { + // Upsert + await likeModel.updateOne({ + userId: new mongoose.Types.ObjectId(userId), + postId: post?.id + }, + {}, + {upsert: true} + ); + } else { + // Delete like document if exists + await likeModel.findOneAndDelete({ + userId: new mongoose.Types.ObjectId(userId), + postId: post?.id + }); + } + } + else{ + throw new Error("Post not found") + } +} + +export const getPostLikesCount = async (postId: string): Promise => { + try { + // Count the number of likes for the given post ID + const likesCount = await likeModel.countDocuments({ postId: new mongoose.Types.ObjectId(postId) }).exec(); + return likesCount; + } catch (error) { + console.error(`Error fetching likes count for post ${postId}:`, error); + throw new Error('Failed to fetch likes count'); + } +}; + +export const getLikedPostsByUser = async (userId: string) => { + const likedPostsByUserId = await likeModel.aggregate([ + { + $match: { + userId: new mongoose.Types.ObjectId(userId) + } + }, + { + $lookup: { + from: 'posts', + localField: 'postId', + foreignField: '_id', + as: 'post' + } + }, + { + $unwind: { + path: '$post' + } + }, + { + $replaceRoot: { + newRoot: '$post' + } + } + ]); + return likedPostsByUserId; +} diff --git a/insighthub-backend/src/services/resources_service.ts b/insighthub-backend/src/services/resources_service.ts new file mode 100644 index 0000000..925bd61 --- /dev/null +++ b/insighthub-backend/src/services/resources_service.ts @@ -0,0 +1,68 @@ +import { config } from '../config/config'; +import multer from 'multer'; +import path from 'path'; +import { randomUUID } from 'crypto'; +import fs from 'fs'; + +const createImagesStorage = () => { + + // Ensure the directory exists + const imagesResourcesDir = config.resources.imagesDirectoryPath(); + if (!fs.existsSync(imagesResourcesDir)) { + fs.mkdirSync(imagesResourcesDir, { recursive: true }); + } + + const imagesStorage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, `${imagesResourcesDir}/`); + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + const id = randomUUID(); + cb(null, id + ext); + } + }); + + // TODO - consider to make a Promise + const uploadImage = multer({ + storage: imagesStorage, + limits: { + fileSize: config.resources.imageMaxSize() + }, + fileFilter: (req, file, cb) => { + const allowedTypes = /jpeg|jpg|png|gif/; + const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); + const mimetype = allowedTypes.test(file.mimetype); + + if (extname && mimetype) { + return cb(null, true); + } else { + return cb(new TypeError(`Invalid file type. Only images are allowed: ${allowedTypes}`)); + } + } + }); + + return uploadImage; +}; + +// TODO - fix the type here +// @ts-ignore +const uploadImage = (req) : Promise => { + return new Promise((resolve, reject) => { + // @ts-ignore + createImagesStorage().single('file')(req, {}, (error) => { + if (error) { + if (error instanceof multer.MulterError || error instanceof TypeError) { + return reject(error); + } else if (!req.file) { + return reject(new TypeError('No file uploaded.')); + } else { + return reject(new Error('Internal Server Error')); + } + } + resolve(req.file.filename); + }); + }); +}; + +export { uploadImage }; \ No newline at end of file diff --git a/insighthub-backend/src/services/socket_service.ts b/insighthub-backend/src/services/socket_service.ts new file mode 100644 index 0000000..3091a2e --- /dev/null +++ b/insighthub-backend/src/services/socket_service.ts @@ -0,0 +1,43 @@ +import { Server } from "socket.io"; +import { config } from "../config/config"; +import messageModel from "../models/message_model"; + +const onlineUsers = new Map(); + +const initSocket = async (socketListener: Server) => { + socketListener.on("connection", async socket => { + + // Online users + const user = (socket as any).user; + if (user) { + delete user.password; + delete user.refreshTokens; + onlineUsers.set(user.id, user); + } + socketListener.sockets.emit(config.socketMethods.onlineUsers, Array.from(onlineUsers.values())); + + // Enter Room + socket.on(config.socketMethods.enterRoom, (roomId) => { + socket.join(roomId); + }); + + // Chat Message + socket.on(config.socketMethods.messageFromClient, async ({ roomId, messageContent }) => { + // Persist message in db + const messageToInsert = { userId: user.id, roomId: roomId, content: messageContent, createdAt: new Date().toISOString() }; + await new messageModel(messageToInsert).validate(); + const insertedMessage = await messageModel.create(messageToInsert); + + // Emit mesage + socketListener.to(roomId).emit(config.socketMethods.messageFromServer, { roomId, message: insertedMessage }); + }); + + // Online users + socket.on("disconnect", () => { + onlineUsers.delete(user.id); + socketListener.sockets.emit(config.socketMethods.onlineUsers, Array.from(onlineUsers.values())); + }); + }); +}; + +export { initSocket }; \ No newline at end of file diff --git a/insighthub-backend/src/services/users_service.ts b/insighthub-backend/src/services/users_service.ts new file mode 100644 index 0000000..266d871 --- /dev/null +++ b/insighthub-backend/src/services/users_service.ts @@ -0,0 +1,201 @@ +import { UserModel } from '../models/user_model'; +import {IUser, UserData} from 'types/user_types'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import { RefreshTokenModel } from '../models/refresh_token_model'; +import {config} from '../config/config' +import {Document} from "mongoose"; +import { BlacklistedTokenModel } from '../models/Blacklisted_token_model'; + +const userToUserData = (user: Document & IUser): UserData => { + return { ...user.toJSON(), id: user.id.toString() }; +}; + + +export const addUser = async (username: string, password: string, email: string, authProvider: string): Promise => { + const newUser = new UserModel({username, password, email, authProvider: authProvider || 'local'}); + await newUser.save() + return userToUserData(newUser); +}; + +export const getUsers = async (): Promise => { + const users = await UserModel.find().exec(); + return users.map(userToUserData); +} + +const getIUserByEmail = async (email: string): Promise => { + return await UserModel.findOne({ email }).exec(); +}; + +export const getUserByEmail = async (email: string): Promise => { + const user = await UserModel.findOne({ email }).exec(); + return user ? userToUserData(user) : null; +}; + +export const getUserById = async (id: string): Promise => { + const user = await UserModel.findById(id).exec(); + return user ? userToUserData(user) : null; +}; + + + +export const updateUserById = async (id: string, updateData: Partial): Promise => { + const user = await UserModel.findByIdAndUpdate(id, updateData, { new: true }).exec(); + return user ? userToUserData(user) : null; +}; + +export const deleteUserById = async (id: string): Promise => { + const user = await UserModel.findByIdAndDelete(id).exec(); + return user ? userToUserData(user) : null; +}; + +export const registerUser = async (username: string, password: string, email: string, authProvider: string): Promise => { + let hashedPassword: string = ''; + // If registering with a password (local registration) + if (password && authProvider === 'local') { + const salt = config.token.salt(); + hashedPassword = await bcrypt.hash(password, salt); + } + return await addUser(username, hashedPassword, email, authProvider); +}; + +export const getUserByUsernameOrEmail = async (username: string, email: string) => { + return await UserModel.findOne({ $or: [{ username }, { email }] }).exec(); +}; + + +/** Generate access and refresh tokens */ +const generateTokens = (id: string): { accessToken: string, refreshToken: string } => { + const random = Math.floor(Math.random() * 1000000); + const accessToken = jwt.sign( + { + userId: id, + random: random + }, + config.token.access_token_secret(), + { expiresIn: config.token.token_expiration() as jwt.SignOptions['expiresIn'] }); + + const refreshToken = jwt.sign( + { + userId: id, + random: random + }, + config.token.refresh_token_secret(), + { expiresIn: config.token.refresh_token_expiration() as jwt.SignOptions['expiresIn'] }); + + return { accessToken, refreshToken }; +} + +export const loginUserGoogle = async (email: string, authProvider: string, name: string, image: string | undefined) => { + let user = await UserModel.findOne({ email }); + + // If the user does not exist, create a new user + if (!user) { + user = await UserModel.create({ + email, + authProvider, + username: name, + imageFilename: image + }); + } + const tokens = generateTokens(user.id.toString()); + return {...tokens, userId: user.id, username: user.username, imageFilename: user.imageFilename} +} + +export const loginUser = async (email: string, password: string, authProvider?: string): Promise<{ accessToken: string, refreshToken: string, userId: string, username: string, imageFilename?: string } | null> => { + const user = await getIUserByEmail(email); + if (!user) { + return null; + } + // Local login (with password) + if (authProvider === 'local') { + if ((await bcrypt.compare(password, user.password))) { + return null; + } + } + // OAuth login (Google) + if (authProvider === 'google') { + if (user.authProvider !== authProvider) { + throw new Error(`User is registered with ${user.authProvider}, not ${authProvider}.`); + } + } + const { accessToken, refreshToken } = generateTokens(user.id); + + await new RefreshTokenModel({ userId: user.id, token: refreshToken, accessToken: accessToken }).save(); + + return { accessToken, refreshToken, userId: user?.id, username: user.username, imageFilename: user.imageFilename }; +}; + +export const refreshToken = async (refreshToken: string): Promise<{ newRefreshToken: string; accessToken: string }> => { + + const existingToken = await findRefreshToken(refreshToken); + if (!existingToken) { + throw new Error('Invalid refresh token'); + } + + const decoded = jwt.verify(refreshToken, config.token.refresh_token_secret()) as { userId: string }; + const user = await getUserById(decoded.userId); + if (!user) { + throw new Error('Invalid refresh token'); + } + + const { accessToken: newAccessToken, refreshToken: newRefreshToken } = generateTokens(user.id); + await updateRefreshTokenAccessToken(refreshToken, newAccessToken, newRefreshToken); + + return { accessToken: newAccessToken, newRefreshToken }; +} + + +/** + * Invalidate the refresh token for a user + * If the user didn't send a refresh -> cancel them all (as required) + * If sent and the token is valid -> cancel it + * If sent and the token is invalid -> return false + * @param refreshToken + * @param userId + */ +export const logoutUser = async (refreshToken: string | undefined, userId: string): Promise => { + if (refreshToken) { + // Find the refresh token document + const tokenDoc = await findRefreshToken(refreshToken); + if (tokenDoc && tokenDoc.userId === userId) { + // Delete the specific refresh token + await RefreshTokenModel.findOneAndDelete({token: refreshToken}).exec(); + await BlacklistedTokenModel.create({token: tokenDoc.accessToken}); + return true; + } else { + // Invalid refresh token + return false; + } + } + return false; + // } else { + // // None or invalid refresh token provided, delete all refresh tokens for the user + // const refreshTokens = await RefreshTokenModel.find({ userId }).exec(); + // for (const tokenDoc of refreshTokens) { + // await BlacklistedTokenModel.create({ token: tokenDoc.accessToken }); + // } + // await RefreshTokenModel.deleteMany({ userId }).exec(); + // return true; + // } +}; + +export const findRefreshToken = async (token: string) => { + return await RefreshTokenModel.findOne({ token }).exec(); +}; + +export const updateRefreshTokenAccessToken = async (oldRefreshToken: string, newAccessToken: string, newRefreshToken: string): Promise => { + await RefreshTokenModel.findOneAndUpdate( + { token: oldRefreshToken }, + { token: newRefreshToken, accessToken: newAccessToken } + ).exec(); +}; + +export const blacklistToken = async (token: string): Promise => { + await new BlacklistedTokenModel({ token }).save(); +}; + +export const isAccessTokenBlacklisted = async (token: string): Promise => { + const blacklistedToken = await BlacklistedTokenModel.findOne({ token }).exec(); + return !!blacklistedToken; +}; \ No newline at end of file diff --git a/insighthub-backend/src/tests/auth_controller.test.ts b/insighthub-backend/src/tests/auth_controller.test.ts new file mode 100644 index 0000000..dcc0f58 --- /dev/null +++ b/insighthub-backend/src/tests/auth_controller.test.ts @@ -0,0 +1,251 @@ +import request from 'supertest'; +import { app } from '../app'; + +type UserInfo = { + email: string; + password: string; + username: string; + accessToken?: string; + refreshToken?: string; //token for every log in, for each device login + userId?: string; +} +const userInfo: UserInfo = { + email: "berrebimevo@gmail.com", + password: "123456", + username: "berrebimevo" +} +const userInfo2: UserInfo = { + email: "berrebimevo@hotmail.fr", + password: "555555", + username: "berrebimevo2" +} + + + +describe('Auth Post Test', () => { + test('Auth Registragtion', async () => { + const response = await request(app).post('/auth/register').send(userInfo); + console.log(response.body); + expect(response.statusCode).toBe(201); + }); + test('Auth Login', async () => { + const response = await request(app).post('/auth/login').send(userInfo); + expect(response.statusCode).toBe(200); + + const accessToken = response.body.accessToken; + const refreshToken = response.body.refreshToken; + const userId = response.body.userId; + + expect(accessToken).toBeDefined(); + expect(refreshToken).toBeDefined(); + expect(userId).toBeDefined(); + + userInfo.accessToken = accessToken; + userInfo.refreshToken = refreshToken; + userInfo.userId = userId; + }); + + test("Make sure two access tokens are not equal", async () => { + const response = await request(app).post('/auth/login').send({ + email: userInfo.email, + password: userInfo.password, + }); + expect(response.body.accessToken).not.toBe(userInfo.accessToken); + }); + + test('Get proyected API when trying to Create Post', async () => { + const response = await request(app).post('/post').send({ + sender: "Invalid Owner", + title: "My First Post", + content: "This is my First Posts" + }); + expect(response.statusCode).not.toBe(201); + expect(response.statusCode).toBe(401); + + const response2 = await request(app).post('/post').set({ + Authorization: "jwt " + userInfo.accessToken + }).send({ + //sender: userInfo.userId, + title: "My First Post", + content: "This is my First Posts" + }); + expect(response2.statusCode).toBe(201); + }); +}); + +describe('Auth Comment Test', () => { + test("Auth Registragtion Comment's user", async () => { + const response = await request(app).post('/auth/register').send(userInfo2); + console.log(response.body); + expect(response.statusCode).toBe(201); + }); + test("Auth Login Comment's user", async () => { + const response = await request(app).post('/auth/login').send(userInfo2); + expect(response.statusCode).toBe(200); + + const accessToken = response.body.accessToken; + const refreshToken = response.body.refreshToken; + const userId = response.body.userId; + + expect(accessToken).toBeDefined(); + expect(refreshToken).toBeDefined(); + expect(userId).toBeDefined(); + userInfo2.accessToken = accessToken; + userInfo2.refreshToken = refreshToken; + userInfo2.userId = userId; + }); + test('Get proyected API when trying to Create Comment', async () => { + const tempPost = await request(app).post('/post').set({ + Authorization: "jwt " + userInfo.accessToken + }).send({ + sender: userInfo.userId, + title: "My First Post", + content: "This is my First Posts" + }); + expect(tempPost.statusCode).toBe(201); + + const response = await request(app).post('/comment').send({ + postId: tempPost.body.id, + // sender: userInfo2.userId, + content: "This is my First Posts" + }); + expect(response.statusCode).not.toBe(201); + expect(response.statusCode).toBe(401); + + const response2 = await request(app).post('/comment').set({ + Authorization: "jwt " + userInfo2.accessToken + }).send({ + postId: tempPost.body.id, + // sender: userInfo2.userId, + content: "This is my First Posts" + }); + expect(response2.statusCode).toBe(201); + }); +}); + +describe('Auth Invalid & Refresh tokens Tests', () => { + beforeAll(() => { + process.env.TOKEN_EXPIRATION = '3s'; + process.env.REFRESH_TOKEN_EXPIRATION = '7d'; + }); + + test('Get proyected API invalid token', async () => { + const response = await request(app).post('/post').set({ + Authorization: "jwt " + userInfo.accessToken + '1' + }).send({ + sender: userInfo.userId, + title: "My First Post", + content: "This is my First Posts" + }); + expect(response.statusCode).not.toBe(201); + }); + + const refreshTokenTest = async () => { + + const response = await request(app).post('/auth/refresh').send({ + refreshToken: userInfo.refreshToken + + }); + expect(response.statusCode).toBe(200); + expect(response.body.accessToken).toBeDefined(); + expect(response.body.refreshToken).toBeDefined(); + userInfo.accessToken = response.body.accessToken; + userInfo.refreshToken = response.body.refreshToken; + } + test("Refresh token", async () => { + await refreshTokenTest(); + }); + test("Refresh token", async () => { + await refreshTokenTest(); + }); + + test("Logout - Invalid refresh token", async () => { + const response = await request(app).post('/auth/logout').set({ + Authorization: "jwt " + userInfo.accessToken + }).send({ + refreshToken: userInfo.refreshToken + }); + expect(response.statusCode).toBe(200); + const response2 = await request(app).post('/auth/refresh').send({ + refreshToken: userInfo.refreshToken + }); + expect(response2.statusCode).toBe(401); + }); + + test("refresh token multiple usage", async () => { + //login - get a Refresh token + const response = await request(app).post('/auth/login').send({ + email: userInfo.email, + password: userInfo.password + }); + expect(response.statusCode).toBe(200); + userInfo.accessToken = response.body.accessToken + userInfo.refreshToken = response.body.refreshToken + + // first time use the refresh token and get a new one + const response2 = await request(app).post('/auth/refresh').send({ + refreshToken: userInfo.refreshToken + }); + expect(response2.statusCode).toBe(200); + + const newRefreshToken = response2.body.refreshToken; + + // second time use the old refresh token and expect to fail + const response3 = await request(app).post('/auth/refresh').send({ + refreshToken: userInfo.refreshToken + }); + expect(response3.statusCode).not.toBe(200); + + // try to use the new refresh token and expect to + const response4 = await request(app).post('/auth/refresh').send({ + refreshToken: newRefreshToken + }); + expect(response4.statusCode).toBe(200); + }); + + jest.setTimeout(10000); + test("timeout on refresh access token", async () => { + const response = await request(app).post('/auth/login').send({ + email: userInfo.email, + password: userInfo.password + }); + expect(response.statusCode).toBe(200); + expect(response.body.accessToken).toBeDefined(); + expect(response.body.refreshToken).toBeDefined(); + userInfo.accessToken = response.body.accessToken + userInfo.refreshToken = response.body.refreshToken + + // wait 6 seconds + await new Promise(resolve => setTimeout(resolve, 6000)); + + //try to access with expired token + const response2 = await request(app).post('/post').set({ + Authorization: "jwt " + userInfo.accessToken + }).send({ + sender: "Invalid owner", + title: "My First Post", + content: "This is my First Posts" + }); + expect(response2.statusCode).not.toBe(201); + + const response3 = await request(app).post('/auth/refresh').send({ + refreshToken: userInfo.refreshToken + }); + expect(response3.statusCode).toBe(200); + userInfo.accessToken = response3.body.accessToken; + userInfo.refreshToken = response3.body.refreshToken; + + const response4 = await request(app).post('/post').set({ + Authorization: "jwt " + userInfo.accessToken + }).send({ + sender: "Invalid owner", + title: "My First Post", + content: "This is my First Posts" + }); + expect(response4.statusCode).toBe(201); + }); + + + +}); + diff --git a/insighthub-backend/src/tests/auth_status_codes.test.ts b/insighthub-backend/src/tests/auth_status_codes.test.ts new file mode 100644 index 0000000..bc65686 --- /dev/null +++ b/insighthub-backend/src/tests/auth_status_codes.test.ts @@ -0,0 +1,193 @@ +import request from 'supertest'; +import { app } from '../app'; + +describe('Authentication Status Code Tests', () => { + let validAccessToken: string; + const expiredAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2NTVmZjJlNzk2ZjY4NDY5OWM5M2Y3ZjYiLCJpYXQiOjE3MDA3NjM4NzksImV4cCI6MTcwMDc2Mzg4MH0.1VroBCccWBUGqwEtQN5QQwo4IpXoP0xYLSQGbZtNeSE'; + let postId: string; + let commentId: string; + + beforeAll(async () => { + // Register and login to get valid access token + const user = { + email: "test@status.com", + username: "TestUser", + password: "123456" + }; + await request(app).post('/auth/register').send(user); + const loginResponse = await request(app).post('/auth/login').send(user); + if (loginResponse.status != 200) { + console.error('login error'); + return; + } + validAccessToken = loginResponse.body.accessToken; + + // Create a post for testing + const postResponse = await request(app) + .post('/post') + .set('Authorization', `jwt ${validAccessToken}`) + .send({ + sender: "TestUser", + title: "Test Post", + content: "Test Content" + }); + postId = postResponse.body.id; + + // Create a comment for testing + const commentResponse = await request(app) + .post('/comment') + .set('Authorization', `jwt ${validAccessToken}`) + .send({ + postId: postId, + sender: "TestUser", + content: "Test Comment" + }); + commentId = commentResponse.body.id; + }); + + describe('200 Success Status Tests', () => { + it('should return 200 on successful GET posts', async () => { + const res = await request(app) + .get('/post') + .set('Authorization', `jwt ${validAccessToken}`); + expect(res.status).toBe(200); + }); + + it('should return 200 on successful GET comments', async () => { + const res = await request(app) + .get('/comment') + .set('Authorization', `jwt ${validAccessToken}`); + expect(res.status).toBe(200); + }); + + it('should return 200 on successful comment deletion', async () => { + const res = await request(app) + .delete(`/comment/${commentId}`) + .set('Authorization', `jwt ${validAccessToken}`); + expect(res.status).toBe(200); + }); + }); + + describe('201 Created Status Tests', () => { + it('should return 201 on successful post creation', async () => { + const res = await request(app) + .post('/post') + .set('Authorization', `jwt ${validAccessToken}`) + .send({ + sender: "TestUser", + title: "New Post", + content: "New Content" + }); + expect(res.status).toBe(201); + }); + + it('should return 201 on successful comment creation', async () => { + const res = await request(app) + .post('/comment') + .set('Authorization', `jwt ${validAccessToken}`) + .send({ + postId: postId, + sender: "TestUser", + content: "New Comment" + }); + expect(res.status).toBe(201); + }); + }); + + describe('400 Bad Request Status Tests', () => { + it('should return 400 when creating post without required fields', async () => { + const res = await request(app) + .post('/post') + .set('Authorization', `jwt ${validAccessToken}`) + .send({ + content: "Missing required fields" + }); + expect(res.status).toBe(400); + }); + + it('should return 400 when creating comment with invalid post ID', async () => { + const res = await request(app) + .post('/comment') + .set('Authorization', `jwt ${validAccessToken}`) + .send({ + postId: "invalidid", + sender: "TestUser", + content: "Test Comment" + }); + expect(res.status).toBe(400); + }); + }); + + describe('401 Unauthorized Status Tests', () => { + it('should return 401 when creating post without token', async () => { + const res = await request(app) + .post('/post') + .send({ + sender: "TestUser", + title: "Test Post", + content: "Test Content" + }); + expect(res.status).toBe(401); + }); + + it('should return 401 when updating comment without token', async () => { + const res = await request(app) + .put(`/comment/${commentId}`) + .send({ + sender: "TestUser", + content: "Updated Comment" + }); + expect(res.status).toBe(401); + }); + }); + + describe('403 Forbidden Status Tests', () => { + it('should return 403 when using expired access token for post creation', async () => { + const res = await request(app) + .post('/post') + .set('Authorization', `jwt ${expiredAccessToken}`) + .send({ + sender: "TestUser", + title: "Test Post", + content: "Test Content" + }); + expect(res.status).toBe(403); + }); + + it('should return 403 when using expired access token for comment update', async () => { + const res = await request(app) + .put(`/comment/${commentId}`) + .set('Authorization', `jwt ${expiredAccessToken}`) + .send({ + sender: "TestUser", + content: "Updated Comment" + }); + expect(res.status).toBe(403); + }); + }); + + describe('404 Not Found Status Tests', () => { + it('should return 404 when accessing non-existent post', async () => { + // Use a valid ObjectId format that doesn't exist in the database + const nonExistentId = '507f1f77bcf86cd799439011'; + const res = await request(app) + .get(`/post/${nonExistentId}`) + .set('Authorization', `jwt ${validAccessToken}`); + expect(res.status).toBe(404); + }); + + it('should return 404 when creating comment for non-existent post', async () => { + // Use a valid ObjectId format that doesn't exist in the database + const nonExistentId = '507f1f77bcf86cd799439011'; + const res = await request(app) + .post('/comment') + .set('Authorization', `jwt ${validAccessToken}`) + .send({ + postId: nonExistentId, + sender: "TestUser", + content: "Test Comment" + }); + expect(res.status).toBe(404); + }); + }); +}); \ No newline at end of file diff --git a/insighthub-backend/src/tests/comments_controller.test.ts b/insighthub-backend/src/tests/comments_controller.test.ts new file mode 100644 index 0000000..2d1b332 --- /dev/null +++ b/insighthub-backend/src/tests/comments_controller.test.ts @@ -0,0 +1,350 @@ +import request from 'supertest'; +import { app } from '../app'; +import {PostData} from "types/post_types"; +import {CommentData} from "types/comment_types"; +import { config } from '../config/config'; + +let existingPost1: PostData; +let existingPost2:PostData; +let existingComment:CommentData; +let accessToken: string; + +const user = { + email: "test2@test.com", + password: "123456", + username: "test2", + id: undefined +}; + +beforeAll(async () => { + // Register and login to get access token + + await request(app).post('/auth/register').send(user); + const loginResponse = await request(app).post('/auth/login').send(user); + user.id = loginResponse.body.userId; + accessToken = loginResponse.body.accessToken; +}); + +describe('given db empty of comments when http request GET /comment', () => { + it('then should return empty list', async () => { + const res = await request(app) + .get('/comment') + .set('Authorization', `jwt ${accessToken}`); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual([]); + }); +}); + +/** + * Already tested in posts_controller. + * We need this just for initializing `existingPost`. + */ +describe('when http request POST /post', () => { + it('then should add post to the db', async () => { + // Post 1 + const body1 = { + "title": "POST1 TITLE", + "content": "POST1 CONTENT" + }; + const res1 = await request(app) + .post('/post') + .set('Authorization', `jwt ${accessToken}`) + .send(body1); + const resBody1 = res1.body; + existingPost1 = { ...resBody1 }; + + // Post 2 + const body2 = { + "title": "POST2 TITLE", + "content": "POST2 CONTENT" + }; + const res2 = await request(app) + .post('/post') + .set('Authorization', `jwt ${accessToken}`) + .send(body2); + const resBody2 = res2.body; + existingPost2 = { ...resBody2 }; + }); +}); + +describe('when http request POST /comment to an unknown post', () => { + it('then should return 400 bad request http status', async () => { + const body = { + "postId": "UNKNOWN", + "content": "COMMENT1 CONTENT" + }; + const res = await request(app) + .post('/comment') + .set('Authorization', `jwt ${accessToken}`) + .send(body); + + expect(res.statusCode).toBe(400); + }); +}); + +describe('when http request POST /comment without required postId field', () => { + it('then should return 400 bad request http status', async () => { + const body = { + "content": "COMMENT1 CONTENT" + }; + const res = await request(app) + .post('/comment') + .set('Authorization', `jwt ${accessToken}`) + .send(body); + + expect(res.statusCode).toBe(400); + }); +}); + +describe('when http request POST /comment to an existing post', () => { + it('then should add comment to the db', async () => { + const body = { + "postId": `${existingPost1.id}`, + "content": "COMMENT1 CONTENT", + owner: undefined + }; + const res = await request(app) + .post('/comment') + .set('Authorization', `jwt ${accessToken}`) + .send(body); + + body.owner = user.id; + const resBody = res.body; + existingComment = { ...resBody }; + delete resBody.id; + delete resBody.createdAt; + delete resBody.updatedAt; + + expect(res.statusCode).toBe(201); + expect(resBody).toEqual(body); + }); +}); + +/** + * Already tested this. + * We need this just for initializing some more comments. + */ +describe('when http request POST /comment to an existing post', () => { + it('then should add comment to the db', async () => { + // Comment 1 + const body1 = { + "postId": `${existingPost1.id}`, + "content": "COMMENT1 CONTENT" + }; + await request(app) + .post('/comment') + .set('Authorization', `jwt ${accessToken}`) + .send(body1); + + // Comment 2 + const body2 = { + "postId": `${existingPost1.id}`, + "content": "COMMENT2 CONTENT" + }; + await request(app) + .post('/comment') + .set('Authorization', `jwt ${accessToken}`) + .send(body2); + + // Comment 3 + const body3 = { + "postId": `${existingPost1.id}`, + "content": "COMMENT3 CONTENT" + }; + await request(app) + .post('/comment') + .set('Authorization', `jwt ${accessToken}`) + .send(body3); + }); +}); + +describe('given db initialized with comments when http request GET /comment', () => { + it('then should return all coments in the db', async () => { + const res = await request(app) + .get('/comment') + .set('Authorization', `jwt ${accessToken}`); + expect(res.statusCode).toBe(200); + expect(res.statusCode).not.toEqual([]); + }); +}); + + + +describe('Check the private and public route for the auth need', () => { + it('should allow GET /comment without authentication', async () => { + const response = await request(app).get('/comment'); + expect(response.status).toBe(200); + }); + + it('should allow GET /comment/:id without authentication', async () => { + const response = await request(app).get(`/comment/${existingComment.id}`); + expect(response.status).toBe(200); + }); + + it('should not allow GET /comment?owner without authentication', async () => { + const response = await request(app).get(`/comment?owner=${existingComment.owner}`); + expect(response.status).toBe(401); + }); + + it('should allow GET /comment/:id without authentication', async () => { + const response = await request(app).get(`/comment/post/${existingComment.postId}`); + expect(response.status).toBe(200); + }); +}); + +describe('when http request PUT /comment/id of unknown post', () => { + it('then should return 200 for update http status', async () => { + const body = { + "postId": "UNKNOWN", + "content": "UPDATED COMMENT CONTENT" + }; + const res = await request(app) + .put(`/comment/${existingComment.id}`) + .set('Authorization', `jwt ${accessToken}`) + .send(body); + const resBody = res.body; + + expect(res.statusCode).toBe(200); + expect(new Date(resBody.updatedAt).getTime()) + .toBeGreaterThan(new Date(resBody.createdAt).getTime()); + delete resBody.id; + delete resBody.createdAt; + delete resBody.updatedAt; + expect(resBody.postId).toEqual(existingPost1.id); + }); +}); + +describe('when http request PUT /comment/id of unknown comment', () => { + it('then should return 400 bad request http status', async () => { + const body = { + "postId": `${existingPost1.id}`, + "content": "UPDATED COMMENT CONTENT" + }; + const res = await request(app) + .put(`/comment/UNKNOWN`) + .set('Authorization', `jwt ${accessToken}`) + .send(body); + + expect(res.statusCode).toBe(400); + }); +}); + +describe('when http request PUT /comment/id without required postId field', () => { + it('then should return 200 created http status', async () => { + const body = { + "content": "UPDATED COMMENT CONTENT" + }; + const res = await request(app) + .put(`/comment/${existingComment.id}`) + .set('Authorization', `jwt ${accessToken}`) + .send(body); + const resBody = res.body; + + expect(res.statusCode).toBe(200); + expect(new Date(resBody.updatedAt).getTime()) + .toBeGreaterThan(new Date(resBody.createdAt).getTime()); + delete resBody.id; + delete resBody.createdAt; + delete resBody.updatedAt; + expect(resBody.postId).toEqual(existingPost1.id); + }); +}); + + +describe('when http request PUT /comment/id of existing post and comment', () => { + it('then should update comment in the db', async () => { + const body = { + "postId": `${existingPost1.id}`, + "content": "UPDATED COMMENT CONTENT" + }; + const res = await request(app) + .put(`/comment/${existingComment.id}`) + .set('Authorization', `jwt ${accessToken}`) + .send(body); + const resBody = res.body; + + expect(res.statusCode).toBe(200); + expect(new Date(resBody.updatedAt).getTime()) + .toBeGreaterThan(new Date(resBody.createdAt).getTime()); + delete resBody.id; + delete resBody.createdAt; + delete resBody.updatedAt; + delete resBody.owner; + expect(resBody).toEqual(body); + }); +}); + +describe('given existing post when http request GET /comment/post/id', () => { + it('then should return its comments only', async () => { + const res = await request(app) + .get(`/comment/post/${existingPost1.id}`) + .set('Authorization', `jwt ${accessToken}`); + expect(res.statusCode).toBe(200); + + const resBody = res.body; + expect(Array.isArray(resBody)).toBe(true); + + if (resBody.length > 0) { + const postIds: string[] = resBody.map((comment: CommentData) => comment.postId); + const uniquePostIds = [...new Set(postIds)]; + expect(uniquePostIds.length).toBe(1); + expect(uniquePostIds[0]).toEqual(existingPost1.id); + } + }); +}); + +describe('given unknown post when http request GET /comment/post/id', () => { + it('then should return 400 bad request http status', async () => { + const res = await request(app) + .get(`/comment/post/UNKNOWN`) + .set('Authorization', `jwt ${accessToken}`); + + expect(res.statusCode).toBe(400); + }); +}); + +describe('given existing post without any comments when http request GET /comment/post/id', () => { + it('then should return list with mocked aiChat comment', async () => { + const res = await request(app) + .get(`/comment/post/${existingPost2.id}`) + .set('Authorization', `jwt ${accessToken}`); + + expect(res.statusCode).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + if (config.chatAi.turned_on()) { + expect(res.body).toHaveLength(1); + expect(res.body[0].content).toBe("Mocked response"); + } + }); +}); + +describe('given unknown comment when http request DELETE /comment/id', () => { + it('then should return 400 bad request http status', async () => { + const res = await request(app) + .delete(`/comment/UNKNOWN`) + .set('Authorization', `jwt ${accessToken}`); + + expect(res.statusCode).toBe(400); + }); +}); + +describe('given existing comment when http request DELETE /comment/id', () => { + it('then should return 200 success http status', async () => { + // First create a comment to delete + const createComment = await request(app) + .post('/comment') + .set('Authorization', `jwt ${accessToken}`) + .send({ + postId: existingPost1.id, + content: "Comment to delete" + }); + + const commentId = createComment.body.id; + + const res = await request(app) + .delete(`/comment/${commentId}`) + .set('Authorization', `jwt ${accessToken}`); + + expect(res.statusCode).toBe(200); + }); +}); diff --git a/insighthub-backend/src/tests/openapi.test.ts b/insighthub-backend/src/tests/openapi.test.ts new file mode 100644 index 0000000..193571e --- /dev/null +++ b/insighthub-backend/src/tests/openapi.test.ts @@ -0,0 +1,33 @@ +import loadOpenApiFile from '../openapi/openapi_loader'; +import fs from 'fs'; + +jest.mock('fs'); + +describe('loadOpenApiFile', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeAll(() => { + // Suppress console.error during tests + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterAll(() => { + // Restore console.error after tests + consoleErrorSpy.mockRestore(); + }); + + it('should return an error when the OpenAPI file cannot be loaded', () => { + // Arrange: Mock fs.readFileSync to throw an error + (fs.readFileSync as jest.Mock).mockImplementation(() => { + throw new Error('File not found'); + }); + + // Act: Call the function + const result = loadOpenApiFile(); + + // Assert: Check if the error is returned + expect(result).toBeInstanceOf(Error); + expect((result as Error).message).toBe('File not found'); + }); + +}); \ No newline at end of file diff --git a/insighthub-backend/src/tests/posts_controller.test.ts b/insighthub-backend/src/tests/posts_controller.test.ts new file mode 100644 index 0000000..a8a3985 --- /dev/null +++ b/insighthub-backend/src/tests/posts_controller.test.ts @@ -0,0 +1,276 @@ +import request from 'supertest'; +import { app } from '../app'; +import {PostModel} from '../models/posts_model'; // Adjust the path as necessary +import {UserModel} from '../models/user_model'; +import {UserData} from "types/user_types"; +import {PostData} from "types/post_types"; // Adjust the path as necessary + +let existingPost: PostData = undefined; + +interface UserInfo extends UserData { + id?: string; + accessToken?: string; + password: string; +} +const userInfo:UserInfo = { + email: "user1@gmail.com", + username: "user1", + password: "123456" +} + +const userInfo2:UserInfo = { + email: "user2@gmail.com", + username: "user2", + password: "123456" +} + +const testPost1 = { + "title": "POST1 TITLE", + "content": "POST1 CONTENT" +}; +const testPost2 = { + "title": "POST2 TITLE", + "content": "POST2 CONTENT" +}; +const testPost3 = { + "title": "POST3 TITLE", + "content": "POST3 CONTENT" +}; +const testUpdatedPost = { + "title": "UPDATED POST TITLE", + "content": "UPDATED POST CONTENT" +}; + +const testPost4 = { + "title": "POST4 TITLE", + "content": "POST4 CONTENT" +} + +const addUser = async (userInfo: UserInfo) => { + await request(app).post('/auth/register').send(userInfo); + const loginResponse = await request(app).post('/auth/login').send(userInfo); + userInfo.accessToken = loginResponse.body.accessToken; + userInfo.id = loginResponse.body.userId; +} + +beforeAll(async () => { + // Clear the DB + await PostModel.deleteMany(); + await UserModel.deleteMany(); + + await addUser(userInfo); + await addUser(userInfo2); +}); + +describe('given db empty of posts when http request GET /post', () => { + it('then should return empty list', async () => { + const res = await request(app) + .get('/post') + .set('Authorization', `jwt ` + userInfo.accessToken); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual([]); + }); +}); + +describe('when http request POST /post', () => { + it('then should add post to the db', async () => { + + const currentTime = Date.now(); + const res = await request(app) + .post('/post') + .set('Authorization', `jwt ` + userInfo.accessToken) + .send(testPost1); + const resBody = res.body; + existingPost = { ...resBody }; + + expect(res.statusCode).toBe(201); + expect(resBody.title).toEqual(testPost1.title); + expect(resBody.content).toEqual(testPost1.content); + expect(resBody.owner).toEqual(userInfo.id); + expect(new Date(resBody.createdAt).getTime()).toBeGreaterThan(currentTime); + + }); +}); + +/** + * Already tested this. + * We need this just for initializing some more posts. + */ +describe('when http request POST /post', () => { + it('then should add posts to the db', async () => { + // Post 1 + await request(app) + .post('/post') + .set('Authorization', `jwt ` + userInfo.accessToken) + .send(testPost1); + // Post 2 + await request(app) + .post('/post') + .set('Authorization', `jwt ` + userInfo.accessToken) + .send(testPost2); + + // Post 3 + await request(app) + .post('/post') + .set('Authorization', `jwt ` + userInfo.accessToken) + .send(testPost3); + + // Post 4 another owner + await request(app) + .post('/post') + .set('Authorization', `jwt ` + userInfo2.accessToken) + .send(testPost4); + }); +}); + +describe('given db initialized with posts when http request GET /post', () => { + it('then should return all posts in the db', async () => { + const res = await request(app) + .get('/post') + .set('Authorization', `jwt ` + userInfo.accessToken); + expect(res.statusCode).toBe(200); + expect(res.body.posts).not.toEqual([]); + expect(res.body.posts.length).toBeGreaterThan(1); + }); +}); + +describe('Check the private and public route for the auth need', () => { + it('should allow GET /post without authentication', async () => { + const response = await request(app).get('/post'); + expect(response.status).toBe(200); + }); + + it('should allow GET /post/:id without authentication', async () => { + const response = await request(app).get(`/post/${existingPost.id}`); + expect(response.status).toBe(200); + }); + + it('should not allow GET /post?owner without authentication', async () => { + const response = await request(app).get(`/post?owner${existingPost.owner}`); + expect(response.status).toBe(401); + }); +}); + +// TODO - change th test to get posts by owner.username +describe('given username when http request GET /post?username', () => { + it('then should return a post', async () => { + const res = await request(app) + .get(`/post?username=${userInfo.username}`) + .set('Authorization', `jwt ` + userInfo.accessToken); + + expect(res.statusCode).toBe(200); + res.body.posts.forEach((post: PostData) => { + expect(post.owner).toEqual(userInfo.id); + }); + }); +}); + +describe('given unknown userId when http request GET /post?owner', () => { + it('then should return empty list', async () => { + const res = await request(app) + .get('/post?owner=67c8975b8a05aa910017a481') + .set('Authorization', `jwt ` + userInfo.accessToken); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual([]); + }); +}); + + +describe('given existing username when http request GET /post?owner', () => { + it('then should return his posts only', async () => { + // Fetch all posts + const resAllPosts = await request(app) + .get('/post') + .set('Authorization', `jwt ` + userInfo.accessToken); + const allPosts = resAllPosts.body; + + // Fetch posts by specific user + const resUserPosts = await request(app) + .get(`/post?owner=${userInfo2.id}`) + .set('Authorization', `jwt ` + userInfo.accessToken); + const userPosts = resUserPosts.body; + + // Check that the response status is 200 + expect(resUserPosts.statusCode).toBe(200); + + // Check that the user posts are a subset of all posts + const userPostIds = userPosts.posts.map((post: { id: string; }) => post.id); + const allPostIds = allPosts.posts.map((post: { id: string; }) => post.id); + userPostIds.forEach((id: string) => { + expect(allPostIds).toContain(id); + }); + + // Check that all posts belong to the user + userPosts.posts.forEach((post: PostData) => { + expect(post.owner).toEqual(userInfo2.id); + }); + }); +}); + +describe('when http request PUT /post/id of unknown post', () => { + it('then should return 400 bad request http status', async () => { + const res = await request(app) + .put(`/post/UNKNOWN`) + .set('Authorization', `jwt ` + userInfo.accessToken) + .send(testUpdatedPost); + + expect(res.statusCode).toBe(400); + }); +}); + +describe('when http request PUT /post/id of existing post', () => { + it('then should update post in the db', async () => { + const resOldPost = await request(app) + .get(`/post/${existingPost.id}`) + .set('Authorization', `jwt ` + userInfo.accessToken) + + const oldPost = resOldPost.body; + const res = await request(app) + .put(`/post/${existingPost.id}`) + .set('Authorization', `jwt ` + userInfo.accessToken) + .send(testUpdatedPost); + const updatedPost = res.body; + + expect(res.statusCode).toBe(200); + + expect(updatedPost.title).toEqual(testUpdatedPost.title); + expect(updatedPost.content).toEqual(testUpdatedPost.content); + expect(updatedPost.owner).toEqual(oldPost.owner); + expect(updatedPost.createdAt).toEqual(oldPost.createdAt); + expect((new Date(updatedPost.updatedAt)).getTime()).toBeGreaterThan((new Date(oldPost.updatedAt)).getTime()); + + }); +}); + + +// TODO - Change to another authenticated user instead of sender +describe('when http request PUT /post/id of existing post but without being the owner', () => { + it('then should return 403 forbidden http status', async () => { + const body = { + "title": "UPDATED POST TITLE", + "content": "UPDATED POST CONTENT" + }; + const res = await request(app) + .put(`/post/${existingPost.id}`) + .set('Authorization', `jwt ` + userInfo2.accessToken) + .send(body); + + expect(res.statusCode).toBe(403); + }); +}); + +describe('when http request PUT /post/id of non existing post', () => { + it('then should return 404 forbidden http status', async () => { + const body = { + "title": "UPDATED POST TITLE", + "content": "UPDATED POST CONTENT" + }; + const res = await request(app) + .put(`/post/67c8a86f81a290f10000e313`) + .set('Authorization', `jwt ` + userInfo.accessToken) + .send(body); + + expect(res.statusCode).toBe(404); + }); +}); \ No newline at end of file diff --git a/insighthub-backend/src/tests/resources_service.test.ts b/insighthub-backend/src/tests/resources_service.test.ts new file mode 100644 index 0000000..80884c7 --- /dev/null +++ b/insighthub-backend/src/tests/resources_service.test.ts @@ -0,0 +1,90 @@ +import request from 'supertest'; +import { app } from '../app'; +import fs from 'fs'; +import path from 'path'; + +describe('Resources Service - Upload Image', () => { + let accessToken: string; + let uploadedFileName: string; + + beforeAll(async () => { + // Register and login to get access token + const user = { + email: "testing111@resources.com", + password: "123456", + username: "testing111" + }; + await request(app).post('/auth/register').send(user); + const loginResponse = await request(app).post('/auth/login').send(user); + accessToken = loginResponse.body.accessToken; + }); + + const generateImageBlob = () => { + // Simulate a small PNG image file + return Buffer.from([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, // IHDR chunk length + 0x49, 0x48, 0x44, 0x52, // IHDR chunk type + 0x00, 0x00, 0x00, 0x01, // width: 1 + 0x00, 0x00, 0x00, 0x01, // height: 1 + 0x08, 0x06, 0x00, 0x00, 0x00, // bit depth, color type, compression, filter, interlace + 0x1F, 0x15, 0xC4, 0x89, // CRC + 0x00, 0x00, 0x00, 0x0A, // IDAT chunk length + 0x49, 0x44, 0x41, 0x54, // IDAT chunk type + 0x78, 0x9C, 0x63, 0x60, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, // compressed data + 0x0D, 0x0A, 0x2D, 0xB4, // CRC + 0x00, 0x00, 0x00, 0x00, // IEND chunk length + 0x49, 0x45, 0x4E, 0x44, // IEND chunk type + 0xAE, 0x42, 0x60, 0x82 // CRC + ]); + }; + + const generateNonImageBlob = () => { + return Buffer.from('This is a test PDF file content', 'utf-8'); + }; + + it('should upload an image successfully', async () => { + const imageBlob = generateImageBlob(); + const res = await request(app) + .post('/resource/image') + .set('Authorization', `jwt ${accessToken}`) + .attach('file', imageBlob, 'test-image.png'); + + expect(res.statusCode).toBe(201); + expect(res.text).toMatch(/\.png$/); + + // Store the uploaded file name for cleanup + uploadedFileName = res.text; + }); + + it('should fail to upload a non-image file', async () => { + const nonImageBlob = generateNonImageBlob(); + const res = await request(app) + .post('/resource/image') + .set('Authorization', `jwt ${accessToken}`) + .attach('file', nonImageBlob, 'test-non-image.pdf'); + + expect(res.statusCode).toBe(400); + expect(res.text).toBe("Invalid file type. Only images are allowed: /jpeg|jpg|png|gif/"); + }); + + it('should fail to upload an image larger than the max size', async () => { + const largeImageBlob = Buffer.alloc(11 * 1024 * 1024); // 11MB + const res = await request(app) + .post('/resource/image') + .set('Authorization', `jwt ${accessToken}`) + .attach('file', largeImageBlob, 'large-test-image.jpg'); + + expect(res.statusCode).toBe(400); + expect(res.text).toBe("File too large"); + }); + + afterAll(async () => { + if (uploadedFileName) { + const filePath = path.resolve(__dirname, '../../resources/images', uploadedFileName); + fs.unlink(filePath, err => { + if (err) throw err; + }); + } + }); +}); \ No newline at end of file diff --git a/insighthub-backend/src/tests/rooms_controller.test.ts b/insighthub-backend/src/tests/rooms_controller.test.ts new file mode 100644 index 0000000..d8cc93c --- /dev/null +++ b/insighthub-backend/src/tests/rooms_controller.test.ts @@ -0,0 +1,89 @@ +import request from 'supertest'; +import { app } from '../app'; +import roomModel from '../models/room_model'; + +describe('Rooms Controller', () => { + let initiatorUser :any = { + username: "test", + email: "test@resources.com", + password: "123456" + }; + let receiverUser :any = { + username: "test2", + email: "test2@resources.com", + password: "123456" + }; + + beforeAll(async () => { + // Register and login to get access token + await request(app).post('/auth/register').send(initiatorUser); + const loginResponse = await request(app).post('/auth/login').send(initiatorUser); + initiatorUser = loginResponse.body; + + // Register and login to get access token + await request(app).post('/auth/register').send(receiverUser); + const loginResponse2 = await request(app).post('/auth/login').send(receiverUser); + receiverUser = loginResponse2.body; + }); + + afterEach(async () => { + // Clean up the database after each test + await roomModel.deleteMany({}); + }); + + it('should return a room if it exists for the given user IDs', async () => { + // Create a room for testing + const room = await roomModel.create({ + userIds: [initiatorUser.userId, receiverUser.userId] + }); + + const response = await request(app) + .get(`/room/user/${receiverUser.userId}`) + .set('Authorization', `Bearer ${initiatorUser.accessToken}`); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('_id', room._id.toString()); + expect(response.body.userIds).toEqual(expect.arrayContaining([receiverUser.userId, initiatorUser.userId])); + }); + + it('should return a room with himself if it exists for the given user IDs', async () => { + // Create a room for testing + const room = await roomModel.create({ + userIds: [initiatorUser.userId, receiverUser.userId] + }); + + // Create a room with himself, for testing + const room1 = await roomModel.create({ + userIds: [initiatorUser.userId, initiatorUser.userId] + }); + + const response = await request(app) + .get(`/room/user/${initiatorUser.userId}`) + .set('Authorization', `Bearer ${initiatorUser.accessToken}`); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('_id', room1._id.toString()); + expect(response.body.userIds).toEqual(expect.arrayContaining([initiatorUser.userId, initiatorUser.userId])); + }); + + it('should create a new room if it does not exist', async () => { + const response = await request(app) + .get(`/room/user/${receiverUser.userId}`) + .set('Authorization', `Bearer ${initiatorUser.accessToken}`); + + expect(response.status).toBe(201); + expect(response.body).toHaveProperty('userIds'); + expect(response.body.userIds).toEqual(expect.arrayContaining([receiverUser.userId, initiatorUser.userId])); + }); + + it('should return 400 for invalid user IDs', async () => { + const invalidUserId = "invalidUserId" + const response = await request(app) + .get(`/room/user/invalidUserId`) + .set('Authorization', `Bearer ${initiatorUser.accessToken}`) + .query({ userId: 'invalidUserId' }); + + expect(response.status).toBe(400); + expect(response.text).toBe(`{"message":"Validation failed","errors":[{"field":"receiverUserId","message":"Invalid user ID","value":"${invalidUserId}"}]}`); + }); +}); \ No newline at end of file diff --git a/insighthub-backend/src/tests/setup-tests.ts b/insighthub-backend/src/tests/setup-tests.ts new file mode 100644 index 0000000..ab0b64b --- /dev/null +++ b/insighthub-backend/src/tests/setup-tests.ts @@ -0,0 +1,54 @@ +import dotenv from "dotenv"; +import dotenvExpand from "dotenv-expand"; +import mongoose, {ConnectOptions} from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; + + + + +/* + * Each `*.test.js` file is called a test "Suite". + * This file configures each test suite. + * + * - The `global.beforeAll` function configures a global hook to run when a + * suite is being initialzed, and is about to begin running its tests. + * - The `global.afterAll` function configures a global hook to run when a + * suite has finished running all of its tests, and is about to close itself. + */ + + +// Configure environment variables, and allow expand. +dotenvExpand.expand(dotenv.config()); + + + +let mongoServer: MongoMemoryServer; + +// Mock the chatWithAI function globally +jest.mock('../services/chat_api_service'); + + +/** + * Before each test suite: + * + * - Generate a new db name. + * - Connect to the database. + */ +global.beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri(), { + useNewUrlParser: true, + useUnifiedTopology: true, + } as ConnectOptions); +}); + +/** + * After each test suite: + * + * - Drop the database. + * - Close the connection to the database. + */ +global.afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); \ No newline at end of file diff --git a/insighthub-backend/src/tests/users_routes.test.ts b/insighthub-backend/src/tests/users_routes.test.ts new file mode 100644 index 0000000..4327954 --- /dev/null +++ b/insighthub-backend/src/tests/users_routes.test.ts @@ -0,0 +1,178 @@ +import request from 'supertest'; +import {app} from '../app'; // Assuming your Express app is exported from this file + + +let accessToken1: string; +let accessToken2: string; + +const user1 = { + email: "userRouteTest1@test.com", + password: "123456", + username: "userRouteTest1", + id: undefined, + createdAt: undefined, + updatedAt: undefined +}; + +const user2 = { + email: "userRouteTest2@test.com", + password: "123456", + username: "userRouteTest2", + id: undefined, + createdAt: undefined, + updatedAt: undefined +}; + + +describe('User Routes', () => { + beforeAll(async () => { + // Generate tokens for authenticated users + // Register and login to get access token + + const regRes1 = await request(app).post('/auth/register').send(user1); + const loginResponse1 = await request(app).post('/auth/login').send(user1); + user1.id = loginResponse1.body.userId; + user1.createdAt = regRes1.body.createdAt; + user1.updatedAt = regRes1.body.updatedAt; + accessToken1 = loginResponse1.body.accessToken; + + // Register and login to get access token + + const regRes2 = await request(app).post('/auth/register').send(user2); + const loginResponse2 = await request(app).post('/auth/login').send(user2); + user2.id = loginResponse2.body.userId; + user2.createdAt = regRes2.body.createdAt; + user2.updatedAt = regRes2.body.updatedAt; + accessToken2 = loginResponse2.body.accessToken; + }); + + describe('GET /user', () => { + it('should return a list of users', async () => { + const res = await request(app) + .get('/user') + .set('Authorization', `Bearer ${accessToken1}`); + expect(res.status).toBe(200); + expect(res.body).toBeInstanceOf(Array); + }); + }); + + describe('GET /user/:id', () => { + it('should return a user by ID', async () => { + const res = await request(app) + .get(`/user/${user1.id}`) + .set('Authorization', `Bearer ${accessToken1}`); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('id', user1.id); + }); + + it('should return 403 even if user not found', async () => { + console.info("the user is not authorized to perform on another user even if not exist") + + const res = await request(app) + .get('/user/000000000000000000000000') + .set('Authorization', `Bearer ${accessToken1}`); + expect(res.status).toBe(403); + }); + }); + + describe('PATCH /user/:id', () => { + it('should update a user by ID', async () => { + const newUser1 = { username: 'UpdatedName', email:"newmail@gmail.com" } + + const res = await request(app) + .patch(`/user/${user1.id}`) + .set('Authorization', `Bearer ${accessToken1}`) + .send(newUser1); + expect(res.status).toBe(200); + expect(res.body.username).toBe(newUser1.username); + expect(res.body.email).toBe(newUser1.email); + expect(res.body.createdAt).toBe(user1.createdAt); + expect(new Date(res.body.updatedAt).getTime()).toBeGreaterThan(new Date(user1.updatedAt as unknown as string).getTime()); + user1.updatedAt = res.body.updatedAt; + user1.username = res.body.username; + user1.email = res.body.email; + }); + + it('should update a user password by ID', async () => { + const newUser1 = { password: '821njK@92', email:"newmail11@gmail.com" } + + const res = await request(app) + .patch(`/user/${user1.id}`) + .set('Authorization', `Bearer ${accessToken1}`) + .send(newUser1); + expect(res.status).toBe(200); + expect(res.body.email).toBe(newUser1.email); + expect(res.body.createdAt).toBe(user1.createdAt); + expect(new Date(res.body.updatedAt).getTime()).toBeGreaterThan(new Date(user1.updatedAt as unknown as string).getTime()); + user1.updatedAt = res.body.updatedAt; + user1.username = res.body.username; + user1.email = res.body.email; + }); + + it('should return 403 if trying to update another user', async () => { + const res = await request(app) + .patch(`/user/${user2.id}`) + .set('Authorization', `Bearer ${accessToken1}`) + .send({ name: 'Updated Name' }); + expect(res.status).toBe(403); + }); + }); + + + describe('User Routes that will not pass for invalid or violations', () => { + describe('GET /user/:id without Authorization header', () => { + it('should return 401 for missing Authorization header', async () => { + const res = await request(app).get(`/user/${user1.id}`); + expect(res.status).toBe(401); + }); + }); + + describe('PATCH /user/:id with empty request body', () => { + it('should return 200 for empty request body - the user is the same, and an empty body is fine', async () => { + const res = await request(app) + .patch(`/user/${user1.id}`) + .set('Authorization', `Bearer ${accessToken1}`) + .send({}); + expect(res.status).toBe(200); + }); + }); + + describe('PATCH /user/:id with invalid data types', () => { + it('should return 400 for invalid data types', async () => { + const res = await request(app) + .patch(`/user/${user1.id}`) + .set('Authorization', `Bearer ${accessToken1}`) + .send({ username: 12345, email: true }); + expect(res.status).toBe(400); + }); + }); + }); + + + describe('DELETE /user/:id', () => { + it('should delete a user by ID', async () => { + const res = await request(app) + .delete(`/user/${user1.id}`) + .set('Authorization', `Bearer ${accessToken1}`); + expect(res.status).toBe(200); + }); + + it('should return 403 if trying to delete another user', async () => { + const res = await request(app) + .delete(`/user/${user2.id}`) + .set('Authorization', `Bearer ${accessToken1}`); + expect(res.status).toBe(403); + }); + + it('should return 403 if user not found', async () => { + console.info("the user is not authorized to perform on another user even if not exists") + + const res = await request(app) + .delete('/user/000000000000000000000000') + .set('Authorization', `Bearer ${accessToken1}`); + expect(res.status).toBe(403); + }); + }); + +}); + diff --git a/insighthub-backend/src/types/comment_types.ts b/insighthub-backend/src/types/comment_types.ts new file mode 100644 index 0000000..61214cd --- /dev/null +++ b/insighthub-backend/src/types/comment_types.ts @@ -0,0 +1,18 @@ +import mongoose, {Document} from "mongoose"; + +export interface IComment extends Document { + postId: mongoose.Schema.Types.ObjectId; + content: string; + owner: string; + +} + +export interface CommentData { + id?: string; + postId: string; + content: string; + owner: string; + createdAt?: Date; + updatedAt?: Date; + ownerProfileImage?: string; +} \ No newline at end of file diff --git a/insighthub-backend/src/types/customRequest.ts b/insighthub-backend/src/types/customRequest.ts new file mode 100644 index 0000000..3f2e67d --- /dev/null +++ b/insighthub-backend/src/types/customRequest.ts @@ -0,0 +1,7 @@ +import { UserData } from "./user_types"; +import { Request } from 'express'; + + +export interface CustomRequest extends Request { + user: UserData; +} \ No newline at end of file diff --git a/insighthub-backend/src/types/post_types.ts b/insighthub-backend/src/types/post_types.ts new file mode 100644 index 0000000..5de3b62 --- /dev/null +++ b/insighthub-backend/src/types/post_types.ts @@ -0,0 +1,19 @@ +import mongoose, { Document } from 'mongoose'; + +export interface IPost extends Document { + id?: string; + title: string; + content?: string; + owner: mongoose.Schema.Types.ObjectId; +} + +export interface PostData { + id?: string; + title: string; + content?: string; + owner: string; + createdAt?: string; + updatedAt?: string; + ownerProfileImage?: string; + ownerUsername?: string; +} \ No newline at end of file diff --git a/insighthub-backend/src/types/user_types.ts b/insighthub-backend/src/types/user_types.ts new file mode 100644 index 0000000..a0afe89 --- /dev/null +++ b/insighthub-backend/src/types/user_types.ts @@ -0,0 +1,22 @@ +import { Document } from 'mongoose'; + +export interface IUser extends Document { + username: string; + email: string; + password: string; + imageFilename?: string; + authProvider?: string; +} + + +export interface UserData { + id: string; + username: string; + email: string; + password: string; + imageFilename?: string; + createdAt?: string, + updatedAt?: string, +} + + diff --git a/insighthub-backend/src/types/validation_errors.ts b/insighthub-backend/src/types/validation_errors.ts new file mode 100644 index 0000000..4e60673 --- /dev/null +++ b/insighthub-backend/src/types/validation_errors.ts @@ -0,0 +1,5 @@ +export interface ValidationError { + field: string; + message: string; + value: any; +} \ No newline at end of file diff --git a/insighthub-backend/src/utils/handle_error.ts b/insighthub-backend/src/utils/handle_error.ts new file mode 100644 index 0000000..b32f9a1 --- /dev/null +++ b/insighthub-backend/src/utils/handle_error.ts @@ -0,0 +1,38 @@ +import { Response } from 'express'; +import {ValidationError} from "types/validation_errors"; +import mongoose from "mongoose"; +import * as expressValidator from "express-validator"; + + +export const isMongoValidationErrors = (err: any) => { + return err instanceof mongoose.Error.ValidationError; +} + +export const isReqValidationErrors = (err: any): err is { + message: any; errors: expressValidator.ValidationError[] +} => { + return Array.isArray(err.errors) && err.errors.every((error: any) => { + return typeof (error.param === 'string' || error.path === 'string') && typeof error.msg === 'string'; + }); +}; + + +export const handleError = (err: any, res: Response) => { + if (isMongoValidationErrors(err)) { + const errors: ValidationError[] = Object.keys(err.errors).map(field => ({ + field, + message: err.errors[field].message, + value: err.errors[field].value + })); + res.status(400).json({ message: err.message, errors }); + } else if (isReqValidationErrors(err)) { + const errors: ValidationError[] = err.errors.map((error: any) => ({ + field: error.parm ?? error.path , + message: error.msg, + value: error.value + })); + res.status(400).json({ message: err.message, errors }); + } else { + res.status(500).json({ message: err.message }); + } +}; \ No newline at end of file diff --git a/insighthub-backend/tsconfig.json b/insighthub-backend/tsconfig.json new file mode 100644 index 0000000..91d9de4 --- /dev/null +++ b/insighthub-backend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": ".", + "baseUrl": ".", + "paths": { + "*": ["node_modules/*"], + "types/*": ["src/types/*"] + } + }, + "include": ["src/**/*.ts", "index.ts", "src/*.ts", "src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/insighthub-frontend/.env.template b/insighthub-frontend/.env.template new file mode 100644 index 0000000..373be25 --- /dev/null +++ b/insighthub-frontend/.env.template @@ -0,0 +1,11 @@ +## This is an extra file + +VITE_PORT= +VITE_BACKEND_URL= +VITE_REACT_APP_FIREBASE_API_KEY= +VITE_REACT_APP_FIREBASE_AUTH_DOMAIN= +VITE_REACT_APP_FIREBASE_PROJECT_ID= +VITE_REACT_APP_FIREBASE_STORAGE_BUCKET= +VITE_REACT_APP_FIREBASE_MESSAGING_SENDER_ID= +VITE_REACT_APP_FIREBASE_APP_ID= +VITE_REACT_APP_FIREBASE_MEASUREMENT_ID= \ No newline at end of file diff --git a/insighthub-frontend/.gitignore b/insighthub-frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/insighthub-frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/insighthub-frontend/README.md b/insighthub-frontend/README.md new file mode 100644 index 0000000..455d09e --- /dev/null +++ b/insighthub-frontend/README.md @@ -0,0 +1,47 @@ +# InsightHub Frontend + +## Usage + +Install the required node packages: + +``` +npm i +``` + +### Development + +Run the application: + +``` +npm run dev +``` + +### Verify Syntax With Linter + +``` +npm run lint +``` + +### Build For Production + +``` +npm run build +``` + +## `.env` + +As a **requirement** for running the application, create an `.env` file in the root working directory and define the environment variables there. + +See [`.env.template`](.env.template) for all the environment variables required. + +### `VITE_PORT` + +The port number to serve the frontend on when executing the `npm run dev` or `npm start` commands. + +For example `5000`. + +### `VITE_BACKEND_URL` + +the backend url for the current frontend + + diff --git a/insighthub-frontend/eslint.config.js b/insighthub-frontend/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/insighthub-frontend/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/insighthub-frontend/index.html b/insighthub-frontend/index.html new file mode 100644 index 0000000..f0d141f --- /dev/null +++ b/insighthub-frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Insight Hub + + +
+ + + diff --git a/insighthub-frontend/package.json b/insighthub-frontend/package.json new file mode 100644 index 0000000..9e0d8e6 --- /dev/null +++ b/insighthub-frontend/package.json @@ -0,0 +1,46 @@ +{ + "name": "insighthub-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^6.4.7", + "@mui/joy": "^5.0.0-beta.51", + "@mui/material": "^6.4.3", + "@types/axios": "^0.9.36", + "@types/react-router-dom": "^5.3.3", + "axios": "^1.7.9", + "dotenv": "^16.4.7", + "firebase": "^11.4.0", + "font-awesome": "^4.7.0", + "lucide-react": "^0.479.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-froala-wysiwyg": "^4.5.0", + "react-router-dom": "^7.1.5", + "socket.io-client": "^4.8.1" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/axios": "^0.14.4", + "@types/node": "^22.13.1", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "typescript": "~5.6.2", + "typescript-eslint": "^8.18.2", + "vite": "^6.0.5" + } +} diff --git a/insighthub-frontend/public/vite.svg b/insighthub-frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/insighthub-frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/insighthub-frontend/src/App.css b/insighthub-frontend/src/App.css new file mode 100644 index 0000000..7a9fb29 --- /dev/null +++ b/insighthub-frontend/src/App.css @@ -0,0 +1,52 @@ +:root { + --color-1: #1877f2; + --color-2: #f0f2f5; + --color-3: #606770; + --color-4: #ccd0d5; + --color-5: #165dbb; + --color-6: #052652; + --color-shadow-1: rgba(0, 0, 0, 0.1); +} + +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/insighthub-frontend/src/App.tsx b/insighthub-frontend/src/App.tsx new file mode 100644 index 0000000..7d9e2f6 --- /dev/null +++ b/insighthub-frontend/src/App.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import Profile from './pages/Profile'; +import './App.css' +import Dashboard from './pages/Dashboard'; +import Footer from './components/Footer'; +import RequireAuth from './hoc/RequireAuth'; +import NewPost from './pages/NewPost'; +import PostDetails from './pages/PostDetails'; +import Chat from './pages/Chat'; + + +const App: React.FC = () => { + return ( + <> + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +