commit 2677abe35ff0831d0f7094ac2ea0a77c08e2fbbc Author: Kar l5 Date: Wed Aug 7 21:43:47 2024 +0530 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b99e7de --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.git +.gitignore diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c6c8b36 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dfae799 --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +MARIA_HOST =172.16.1.1 +MARIA_USER =beanstalk +MARIA_PASS =Zu*B]Lx_heDFVtt] +MARIA_DBNM =beanstalk_iimtt +CORS_ALLOWED_ORIGINS=http://192.168.0.166:2022,http://192.168.0.166:2051 +OPENAI_KEY =sk-proj-AjYBwdJwg6nzuQVsfdgsdfgsdfsdfdfsdfsdfI0AdLNNOYkJZ +MONGODB_URL=mongodb://root:pass@10.0.0.90:27017 +MONGO_DB_NAME=iimttnewdb + +AWS_ACCESS_KEY_ID=AKIDFDFSDFDFDFQ4NURNQB +AWS_SECRET_ACCESS_KEY=V6T9uZxU/zx3xa9cZ6M/fhfhgfxghxfghgg+DptMbwjXS +AWS_REGION=ap-south-1 +S3_BUCKET_NAME=gamescreenshot + +# Port number +PORT=5174 +HOST = 0.0.0.0 +# URL of the Mongo DB +#MONGODB_URL=mongodb://127.0.0.1:27017/node-boilerplate + +# JWT +# JWT secret key +JWT_SECRET=thisisasamplesecret +# Number of minutes after which an access token expires +JWT_ACCESS_EXPIRATION_MINUTES=30 +# Number of days after which a refresh token expires +JWT_REFRESH_EXPIRATION_DAYS=30 +# Number of minutes after which a reset password token expires +JWT_RESET_PASSWORD_EXPIRATION_MINUTES=10 +# Number of minutes after which a verify email token expires +JWT_VERIFY_EMAIL_EXPIRATION_MINUTES=10 + +# SMTP configuration options for the email service +# For testing, you can use a fake SMTP service like Ethereal: https://ethereal.email/create +SMTP_HOST=email-server +SMTP_PORT=587 +SMTP_USERNAME=email-server-username +SMTP_PASSWORD=email-server-password +EMAIL_FROM=support@yourapp.com diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ed4598e --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +node_modules +bin diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..c1bcc4b --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,19 @@ +{ + "env": { + "node": true, + "jest": true + }, + "extends": ["airbnb-base", "plugin:jest/recommended", "plugin:security/recommended", "plugin:prettier/recommended"], + "plugins": ["jest", "security", "prettier"], + "parserOptions": { + "ecmaVersion": 2018 + }, + "rules": { + "no-console": "error", + "func-names": "off", + "no-underscore-dangle": "off", + "consistent-return": "off", + "jest/expect-expect": "off", + "security/detect-object-injection": "off" + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..70ed8e9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Convert text file line endings to lf +* text eol=lf +*.js text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e516023 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Dependencies +node_modules + +# yarn error logs +yarn-error.log + +# Environment varibales +.env* +!.env*.example + +# Code coverage +coverage diff --git a/.husky/post-checkout b/.husky/post-checkout new file mode 100644 index 0000000..15f7266 --- /dev/null +++ b/.husky/post-checkout @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn install diff --git a/.husky/post-commit b/.husky/post-commit new file mode 100644 index 0000000..fd4c0ef --- /dev/null +++ b/.husky/post-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +git status diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..d2ae35e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn lint-staged diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 0000000..5189da9 --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,3 @@ +{ + "*.js": "eslint" +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..6895bf0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +node_modules +coverage + diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..db10690 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "printWidth": 125 +} diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..95064a5 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,20 @@ +language: node_js +node_js: + - '12' +services: + - mongodb +cache: yarn +branches: + only: + - master +env: + global: + - PORT=3000 + - MONGODB_URL=mongodb://localhost:27017/node-boilerplate + - JWT_SECRET=thisisasamplesecret + - JWT_ACCESS_EXPIRATION_MINUTES=30 + - JWT_REFRESH_EXPIRATION_DAYS=30 +script: + - yarn lint + - yarn test +after_success: yarn coverage:coveralls diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f509987 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM node:alpine + +RUN mkdir -p /usr/src/node-app && chown -R node:node /usr/src/node-app + +WORKDIR /usr/src/node-app + +COPY package.json yarn.lock ./ + +USER node + +RUN yarn install --pure-lockfile + +COPY --chown=node:node . . + +EXPOSE 3000 diff --git a/README.md b/README.md new file mode 100644 index 0000000..00e852e --- /dev/null +++ b/README.md @@ -0,0 +1,433 @@ +# RESTful API Node Server Boilerplate + + +## Quick Start + +To create a project, simply run: + +```bash +npx create-nodejs-express-app +``` + +Or + +```bash +npm init nodejs-express-app +``` + +## Manual Installation + +If you would still prefer to do the installation manually, follow these steps: + +Clone the repo: + +```bash +git clone --depth 1 https://github.com/hagopj13/node-express-boilerplate.git +cd node-express-boilerplate +npx rimraf ./.git +``` + +Install the dependencies: + +```bash +yarn install +``` + +Set the environment variables: + +```bash +cp .env.example .env + +# open .env and modify the environment variables (if needed) +``` + +## Table of Contents + +- [Features](#features) +- [Commands](#commands) +- [Environment Variables](#environment-variables) +- [Project Structure](#project-structure) +- [API Documentation](#api-documentation) +- [Error Handling](#error-handling) +- [Validation](#validation) +- [Authentication](#authentication) +- [Authorization](#authorization) +- [Logging](#logging) +- [Custom Mongoose Plugins](#custom-mongoose-plugins) +- [Linting](#linting) +- [Contributing](#contributing) + +## Features + +- **NoSQL database**: [MongoDB](https://www.mongodb.com) object data modeling using [Mongoose](https://mongoosejs.com) +- **Authentication and authorization**: using [passport](http://www.passportjs.org) +- **Validation**: request data validation using [Joi](https://github.com/hapijs/joi) +- **Logging**: using [winston](https://github.com/winstonjs/winston) and [morgan](https://github.com/expressjs/morgan) +- **Testing**: unit and integration tests using [Jest](https://jestjs.io) +- **Error handling**: centralized error handling mechanism +- **API documentation**: with [swagger-jsdoc](https://github.com/Surnet/swagger-jsdoc) and [swagger-ui-express](https://github.com/scottie1984/swagger-ui-express) +- **Process management**: advanced production process management using [PM2](https://pm2.keymetrics.io) +- **Dependency management**: with [Yarn](https://yarnpkg.com) +- **Environment variables**: using [dotenv](https://github.com/motdotla/dotenv) and [cross-env](https://github.com/kentcdodds/cross-env#readme) +- **Security**: set security HTTP headers using [helmet](https://helmetjs.github.io) +- **Santizing**: sanitize request data against xss and query injection +- **CORS**: Cross-Origin Resource-Sharing enabled using [cors](https://github.com/expressjs/cors) +- **Compression**: gzip compression with [compression](https://github.com/expressjs/compression) +- **CI**: continuous integration with [Travis CI](https://travis-ci.org) +- **Docker support** +- **Code coverage**: using [coveralls](https://coveralls.io) +- **Code quality**: with [Codacy](https://www.codacy.com) +- **Git hooks**: with [husky](https://github.com/typicode/husky) and [lint-staged](https://github.com/okonet/lint-staged) +- **Linting**: with [ESLint](https://eslint.org) and [Prettier](https://prettier.io) +- **Editor config**: consistent editor configuration using [EditorConfig](https://editorconfig.org) + +## Commands + +Running locally: + +```bash +yarn dev +``` + +Running in production: + +```bash +yarn start +``` + +Testing: + +```bash +# run all tests +yarn test + +# run all tests in watch mode +yarn test:watch + +# run test coverage +yarn coverage +``` + +Docker: + +```bash +# run docker container in development mode +yarn docker:dev + +# run docker container in production mode +yarn docker:prod + +# run all tests in a docker container +yarn docker:test +``` + +Linting: + +```bash +# run ESLint +yarn lint + +# fix ESLint errors +yarn lint:fix + +# run prettier +yarn prettier + +# fix prettier errors +yarn prettier:fix +``` + +## Environment Variables + +The environment variables can be found and modified in the `.env` file. They come with these default values: + +```bash +# Port number +PORT=3000 + +# URL of the Mongo DB +MONGODB_URL=mongodb://127.0.0.1:27017/node-boilerplate + +# JWT +# JWT secret key +JWT_SECRET=thisisasamplesecret +# Number of minutes after which an access token expires +JWT_ACCESS_EXPIRATION_MINUTES=30 +# Number of days after which a refresh token expires +JWT_REFRESH_EXPIRATION_DAYS=30 + +# SMTP configuration options for the email service +# For testing, you can use a fake SMTP service like Ethereal: https://ethereal.email/create +SMTP_HOST=email-server +SMTP_PORT=587 +SMTP_USERNAME=email-server-username +SMTP_PASSWORD=email-server-password +EMAIL_FROM=support@yourapp.com +``` + +## Project Structure + +``` +src\ + |--config\ # Environment variables and configuration related things + |--controllers\ # Route controllers (controller layer) + |--docs\ # Swagger files + |--middlewares\ # Custom express middlewares + |--models\ # Mongoose models (data layer) + |--routes\ # Routes + |--services\ # Business logic (service layer) + |--utils\ # Utility classes and functions + |--validations\ # Request data validation schemas + |--app.js # Express app + |--index.js # App entry point +``` + +## API Documentation + +To view the list of available APIs and their specifications, run the server and go to `http://localhost:3000/v1/docs` in your browser. This documentation page is automatically generated using the [swagger](https://swagger.io/) definitions written as comments in the route files. + +### API Endpoints + +List of available routes: + +**Auth routes**:\ +`POST /v1/auth/register` - register\ +`POST /v1/auth/login` - login\ +`POST /v1/auth/refresh-tokens` - refresh auth tokens\ +`POST /v1/auth/forgot-password` - send reset password email\ +`POST /v1/auth/reset-password` - reset password\ +`POST /v1/auth/send-verification-email` - send verification email\ +`POST /v1/auth/verify-email` - verify email + +**User routes**:\ +`POST /v1/users` - create a user\ +`GET /v1/users` - get all users\ +`GET /v1/users/:userId` - get user\ +`PATCH /v1/users/:userId` - update user\ +`DELETE /v1/users/:userId` - delete user + +## Error Handling + +The app has a centralized error handling mechanism. + +Controllers should try to catch the errors and forward them to the error handling middleware (by calling `next(error)`). For convenience, you can also wrap the controller inside the catchAsync utility wrapper, which forwards the error. + +```javascript +const catchAsync = require('../utils/catchAsync'); + +const controller = catchAsync(async (req, res) => { + // this error will be forwarded to the error handling middleware + throw new Error('Something wrong happened'); +}); +``` + +The error handling middleware sends an error response, which has the following format: + +```json +{ + "code": 404, + "message": "Not found" +} +``` + +When running in development mode, the error response also contains the error stack. + +The app has a utility ApiError class to which you can attach a response code and a message, and then throw it from anywhere (catchAsync will catch it). + +For example, if you are trying to get a user from the DB who is not found, and you want to send a 404 error, the code should look something like: + +```javascript +const httpStatus = require('http-status'); +const ApiError = require('../utils/ApiError'); +const User = require('../models/User'); + +const getUser = async (userId) => { + const user = await User.findById(userId); + if (!user) { + throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); + } +}; +``` + +## Validation + +Request data is validated using [Joi](https://joi.dev/). Check the [documentation](https://joi.dev/api/) for more details on how to write Joi validation schemas. + +The validation schemas are defined in the `src/validations` directory and are used in the routes by providing them as parameters to the `validate` middleware. + +```javascript +const express = require('express'); +const validate = require('../../middlewares/validate'); +const userValidation = require('../../validations/user.validation'); +const userController = require('../../controllers/user.controller'); + +const router = express.Router(); + +router.post('/users', validate(userValidation.createUser), userController.createUser); +``` + +## Authentication + +To require authentication for certain routes, you can use the `auth` middleware. + +```javascript +const express = require('express'); +const auth = require('../../middlewares/auth'); +const userController = require('../../controllers/user.controller'); + +const router = express.Router(); + +router.post('/users', auth(), userController.createUser); +``` + +These routes require a valid JWT access token in the Authorization request header using the Bearer schema. If the request does not contain a valid access token, an Unauthorized (401) error is thrown. + +**Generating Access Tokens**: + +An access token can be generated by making a successful call to the register (`POST /v1/auth/register`) or login (`POST /v1/auth/login`) endpoints. The response of these endpoints also contains refresh tokens (explained below). + +An access token is valid for 30 minutes. You can modify this expiration time by changing the `JWT_ACCESS_EXPIRATION_MINUTES` environment variable in the .env file. + +**Refreshing Access Tokens**: + +After the access token expires, a new access token can be generated, by making a call to the refresh token endpoint (`POST /v1/auth/refresh-tokens`) and sending along a valid refresh token in the request body. This call returns a new access token and a new refresh token. + +A refresh token is valid for 30 days. You can modify this expiration time by changing the `JWT_REFRESH_EXPIRATION_DAYS` environment variable in the .env file. + +## Authorization + +The `auth` middleware can also be used to require certain rights/permissions to access a route. + +```javascript +const express = require('express'); +const auth = require('../../middlewares/auth'); +const userController = require('../../controllers/user.controller'); + +const router = express.Router(); + +router.post('/users', auth('manageUsers'), userController.createUser); +``` + +In the example above, an authenticated user can access this route only if that user has the `manageUsers` permission. + +The permissions are role-based. You can view the permissions/rights of each role in the `src/config/roles.js` file. + +If the user making the request does not have the required permissions to access this route, a Forbidden (403) error is thrown. + +## Logging + +Import the logger from `src/config/logger.js`. It is using the [Winston](https://github.com/winstonjs/winston) logging library. + +Logging should be done according to the following severity levels (ascending order from most important to least important): + +```javascript +const logger = require('/config/logger'); + +logger.error('message'); // level 0 +logger.warn('message'); // level 1 +logger.info('message'); // level 2 +logger.http('message'); // level 3 +logger.verbose('message'); // level 4 +logger.debug('message'); // level 5 +``` + +In development mode, log messages of all severity levels will be printed to the console. + +In production mode, only `info`, `warn`, and `error` logs will be printed to the console.\ +It is up to the server (or process manager) to actually read them from the console and store them in log files.\ +This app uses pm2 in production mode, which is already configured to store the logs in log files. + +Note: API request information (request url, response code, timestamp, etc.) are also automatically logged (using [morgan](https://github.com/expressjs/morgan)). + +## Custom Mongoose Plugins + +The app also contains 2 custom mongoose plugins that you can attach to any mongoose model schema. You can find the plugins in `src/models/plugins`. + +```javascript +const mongoose = require('mongoose'); +const { toJSON, paginate } = require('./plugins'); + +const userSchema = mongoose.Schema( + { + /* schema definition here */ + }, + { timestamps: true } +); + +userSchema.plugin(toJSON); +userSchema.plugin(paginate); + +const User = mongoose.model('User', userSchema); +``` + +### toJSON + +The toJSON plugin applies the following changes in the toJSON transform call: + +- removes \_\_v, createdAt, updatedAt, and any schema path that has private: true +- replaces \_id with id + +### paginate + +The paginate plugin adds the `paginate` static method to the mongoose schema. + +Adding this plugin to the `User` model schema will allow you to do the following: + +```javascript +const queryUsers = async (filter, options) => { + const users = await User.paginate(filter, options); + return users; +}; +``` + +The `filter` param is a regular mongo filter. + +The `options` param can have the following (optional) fields: + +```javascript +const options = { + sortBy: 'name:desc', // sort order + limit: 5, // maximum results per page + page: 2, // page number +}; +``` + +The plugin also supports sorting by multiple criteria (separated by a comma): `sortBy: name:desc,role:asc` + +The `paginate` method returns a Promise, which fulfills with an object having the following properties: + +```json +{ + "results": [], + "page": 2, + "limit": 5, + "totalPages": 10, + "totalResults": 48 +} +``` + +## Linting + +Linting is done using [ESLint](https://eslint.org/) and [Prettier](https://prettier.io). + +In this app, ESLint is configured to follow the [Airbnb JavaScript style guide](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb-base) with some modifications. It also extends [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) to turn off all rules that are unnecessary or might conflict with Prettier. + +To modify the ESLint configuration, update the `.eslintrc.json` file. To modify the Prettier configuration, update the `.prettierrc.json` file. + +To prevent a certain file or directory from being linted, add it to `.eslintignore` and `.prettierignore`. + +To maintain a consistent coding style across different IDEs, the project contains `.editorconfig` + +## Contributing + +Contributions are more than welcome! Please check out the [contributing guide](CONTRIBUTING.md). + +## Inspirations + +- [danielfsousa/express-rest-es2017-boilerplate](https://github.com/danielfsousa/express-rest-es2017-boilerplate) +- [madhums/node-express-mongoose](https://github.com/madhums/node-express-mongoose) +- [kunalkapadia/express-mongoose-es6-rest-api](https://github.com/kunalkapadia/express-mongoose-es6-rest-api) + +## License +taken from https://github.com/hagopj13/node-express-boilerplate +[MIT](LICENSE) diff --git a/bin/createNodejsApp.js b/bin/createNodejsApp.js new file mode 100644 index 0000000..a395b59 --- /dev/null +++ b/bin/createNodejsApp.js @@ -0,0 +1,111 @@ +#!/usr/bin/env node +const util = require('util'); +const path = require('path'); +const fs = require('fs'); +const { execSync } = require('child_process'); + +// Utility functions +const exec = util.promisify(require('child_process').exec); +async function runCmd(command) { + try { + const { stdout, stderr } = await exec(command); + console.log(stdout); + console.log(stderr); + } catch { + (error) => { + console.log(error); + }; + } +} + +async function hasYarn() { + try { + await execSync('yarnpkg --version', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +// Validate arguments +if (process.argv.length < 3) { + console.log('Please specify the target project directory.'); + console.log('For example:'); + console.log(' npx create-nodejs-app my-app'); + console.log(' OR'); + console.log(' npm init nodejs-app my-app'); + process.exit(1); +} + +// Define constants +const ownPath = process.cwd(); +const folderName = process.argv[2]; +const appPath = path.join(ownPath, folderName); +const repo = 'https://github.com/hagopj13/node-express-boilerplate.git'; + +// Check if directory already exists +try { + fs.mkdirSync(appPath); +} catch (err) { + if (err.code === 'EEXIST') { + console.log('Directory already exists. Please choose another name for the project.'); + } else { + console.log(err); + } + process.exit(1); +} + +async function setup() { + try { + // Clone repo + console.log(`Downloading files from repo ${repo}`); + await runCmd(`git clone --depth 1 ${repo} ${folderName}`); + console.log('Cloned successfully.'); + console.log(''); + + // Change directory + process.chdir(appPath); + + // Install dependencies + const useYarn = await hasYarn(); + console.log('Installing dependencies...'); + if (useYarn) { + await runCmd('yarn install'); + } else { + await runCmd('npm install'); + } + console.log('Dependencies installed successfully.'); + console.log(); + + // Copy envornment variables + fs.copyFileSync(path.join(appPath, '.env.example'), path.join(appPath, '.env')); + console.log('Environment files copied.'); + + // Delete .git folder + await runCmd('npx rimraf ./.git'); + + // Remove extra files + fs.unlinkSync(path.join(appPath, 'CHANGELOG.md')); + fs.unlinkSync(path.join(appPath, 'CODE_OF_CONDUCT.md')); + fs.unlinkSync(path.join(appPath, 'CONTRIBUTING.md')); + fs.unlinkSync(path.join(appPath, 'bin', 'createNodejsApp.js')); + fs.rmdirSync(path.join(appPath, 'bin')); + if (!useYarn) { + fs.unlinkSync(path.join(appPath, 'yarn.lock')); + } + + console.log('Installation is now complete!'); + console.log(); + + console.log('We suggest that you start by typing:'); + console.log(` cd ${folderName}`); + console.log(useYarn ? ' yarn dev' : ' npm run dev'); + console.log(); + console.log('Enjoy your production-ready Node.js app, which already supports a large number of ready-made features!'); + console.log('Check README.md for more info.'); + } catch (error) { + console.log(error); + } +} + +setup(); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..a14d287 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,6 @@ +version: '3' + +services: + node-app: + container_name: node-app-dev + command: yarn dev -L diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..d53fe52 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,6 @@ +version: '3' + +services: + node-app: + container_name: node-app-prod + command: yarn start diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..e06adaf --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,6 @@ +version: '3' + +services: + node-app: + container_name: node-app-test + command: yarn test diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a6ff511 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3' + +services: + node-app: + build: . + image: node-app + environment: + - MONGODB_URL=mongodb://mongodb:27017/node-boilerplate + ports: + - '3000:3000' + depends_on: + - mongodb + volumes: + - .:/usr/src/node-app + networks: + - node-network + + mongodb: + image: mongo:4.2.1-bionic + ports: + - '27017:27017' + volumes: + - dbdata:/data/db + networks: + - node-network + +volumes: + dbdata: + +networks: + node-network: + driver: bridge diff --git a/ecosystem.config.json b/ecosystem.config.json new file mode 100644 index 0000000..0966d28 --- /dev/null +++ b/ecosystem.config.json @@ -0,0 +1,15 @@ +{ + "apps": [ + { + "name": "app", + "script": "src/index.js", + "instances": 1, + "autorestart": true, + "watch": false, + "time": true, + "env": { + "NODE_ENV": "production" + } + } + ] +} diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..e844272 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + testEnvironment: 'node', + testEnvironmentOptions: { + NODE_ENV: 'test', + }, + restoreMocks: true, + coveragePathIgnorePatterns: ['node_modules', 'src/config', 'src/app.js', 'tests'], + coverageReporters: ['text', 'lcov', 'clover', 'html'], +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..3e6cb2f --- /dev/null +++ b/package.json @@ -0,0 +1,96 @@ +{ + "name": "iimttapi", + "version": "0.0.1", + "description": "Create a Node.js app for building production-ready RESTful APIs using Express, by running one command", + "bin": "bin/createNodejsApp.js", + "main": "src/index.js", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "scripts": { + "start": "pm2 start ecosystem.config.json --no-daemon", + "dev": "cross-env NODE_ENV=development nodemon src/index.js", + "test": "jest -i --colors --verbose --detectOpenHandles", + "test:watch": "jest -i --watchAll", + "coverage": "jest -i --coverage", + "coverage:coveralls": "jest -i --coverage --coverageReporters=text-lcov | coveralls", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "prettier": "prettier --check **/*.js", + "prettier:fix": "prettier --write **/*.js", + "docker:prod": "docker-compose -f docker-compose.yml -f docker-compose.prod.yml up", + "docker:dev": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml up", + "docker:test": "docker-compose -f docker-compose.yml -f docker-compose.test.yml up", + "prepare": "husky install" + }, + "keywords": [ + "node", + "node.js", + "boilerplate", + "generator", + "express", + "rest", + "api", + "mongodb", + "mongoose", + "es6", + "es7", + "es8", + "es9", + "jest", + "travis", + "docker", + "passport", + "joi", + "eslint", + "prettier" + ], + "dependencies": { + "aws-sdk": "^2.1669.0", + "bcryptjs": "^2.4.3", + "body-parser": "^1.20.2", + "compression": "^1.7.4", + "cors": "^2.8.5", + "cross-env": "^7.0.0", + "dotenv": "^10.0.0", + "express": "^4.17.1", + "express-mongo-sanitize": "^2.0.0", + "express-rate-limit": "^5.0.0", + "helmet": "^4.1.0", + "http-status": "^1.4.0", + "joi": "^17.3.0", + "jsonwebtoken": "^8.5.1", + "moment": "^2.24.0", + "mongoose": "^5.7.7", + "morgan": "^1.9.1", + "mysql2": "^3.11.0", + "nodemailer": "^6.3.1", + "passport": "^0.4.0", + "passport-jwt": "^4.0.0", + "pm2": "^5.1.0", + "swagger-jsdoc": "^6.0.8", + "swagger-ui-express": "^4.1.6", + "validator": "^13.0.0", + "winston": "^3.2.1", + "xss-clean": "^0.1.1" + }, + "devDependencies": { + "coveralls": "^3.0.7", + "eslint": "^7.0.0", + "eslint-config-airbnb-base": "^14.0.0", + "eslint-config-prettier": "^8.1.0", + "eslint-plugin-import": "^2.18.2", + "eslint-plugin-jest": "^24.0.1", + "eslint-plugin-prettier": "^3.1.1", + "eslint-plugin-security": "^1.4.0", + "faker": "^5.1.0", + "husky": "7.0.4", + "jest": "^26.0.1", + "lint-staged": "^11.0.0", + "node-mocks-http": "^1.8.0", + "nodemon": "^2.0.0", + "prettier": "^2.0.5", + "supertest": "^6.0.1" + } +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..82af052 --- /dev/null +++ b/src/app.js @@ -0,0 +1,88 @@ +const express = require('express'); +const helmet = require('helmet'); +const xss = require('xss-clean'); +const mongoSanitize = require('express-mongo-sanitize'); +const compression = require('compression'); +const cors = require('cors'); +const passport = require('passport'); +const httpStatus = require('http-status'); +const config = require('./config/config'); +const morgan = require('./config/morgan'); +const { jwtStrategy } = require('./config/passport'); +const { authLimiter } = require('./middlewares/rateLimiter'); +const routes = require('./routes/v1'); +const { errorConverter, errorHandler } = require('./middlewares/error'); +const ApiError = require('./utils/ApiError'); +const bodyParser = require('body-parser'); + +const app = express(); + +if (config.env !== 'test') { + app.use(morgan.successHandler); + app.use(morgan.errorHandler); +} + +// Middleware to parse JSON bodies +// app.use(bodyParser.json()); +app.use(bodyParser.json({ limit: '10mb' })); + +// set security HTTP headers +app.use(helmet()); + +// parse json request body +app.use(express.json()); + +// parse urlencoded request body +app.use(express.urlencoded({ extended: true })); + +// sanitize request data +app.use(xss()); +app.use(mongoSanitize()); + +// gzip compression +app.use(compression()); + +// enable cors +app.use(cors()); +app.options('*', cors()); + +//# Need to implement +// const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS.split(','); +// const corsOptions = { +// origin: function (origin, callback) { +// // Allow requests with no origin (like mobile apps, curl requests) +// if (!origin) return callback(null, true); +// if (allowedOrigins.indexOf(origin) !== -1) { +// callback(null, true); +// } else { +// callback(new Error('Not allowed by CORS')); +// } +// } +// }; +// app.use(cors(corsOptions)); +// app.use(cors()); + +// jwt authentication +app.use(passport.initialize()); +passport.use('jwt', jwtStrategy); + +// limit repeated failed requests to auth endpoints +if (config.env === 'production') { + app.use('/v1/auth', authLimiter); +} + +// v1 api routes +app.use('/', routes); + +// send back a 404 error for any unknown api request +app.use((req, res, next) => { + next(new ApiError(httpStatus.NOT_FOUND, 'Not found')); +}); + +// convert error to ApiError, if needed +app.use(errorConverter); + +// handle error +app.use(errorHandler); + +module.exports = app; diff --git a/src/config/config.js b/src/config/config.js new file mode 100644 index 0000000..a34e9d0 --- /dev/null +++ b/src/config/config.js @@ -0,0 +1,64 @@ +const dotenv = require('dotenv'); +const path = require('path'); +const Joi = require('joi'); + +dotenv.config({ path: path.join(__dirname, '../../.env') }); + +const envVarsSchema = Joi.object() + .keys({ + NODE_ENV: Joi.string().valid('production', 'development', 'test').required(), + PORT: Joi.number().default(3000), + MONGODB_URL: Joi.string().required().description('Mongo DB url'), + JWT_SECRET: Joi.string().required().description('JWT secret key'), + JWT_ACCESS_EXPIRATION_MINUTES: Joi.number().default(30).description('minutes after which access tokens expire'), + JWT_REFRESH_EXPIRATION_DAYS: Joi.number().default(30).description('days after which refresh tokens expire'), + JWT_RESET_PASSWORD_EXPIRATION_MINUTES: Joi.number() + .default(10) + .description('minutes after which reset password token expires'), + JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: Joi.number() + .default(10) + .description('minutes after which verify email token expires'), + SMTP_HOST: Joi.string().description('server that will send the emails'), + SMTP_PORT: Joi.number().description('port to connect to the email server'), + SMTP_USERNAME: Joi.string().description('username for email server'), + SMTP_PASSWORD: Joi.string().description('password for email server'), + EMAIL_FROM: Joi.string().description('the from field in the emails sent by the app'), + }) + .unknown(); + +const { value: envVars, error } = envVarsSchema.prefs({ errors: { label: 'key' } }).validate(process.env); + +if (error) { + throw new Error(`Config validation error: ${error.message}`); +} + +module.exports = { + env: envVars.NODE_ENV, + port: envVars.PORT, + mongoose: { + url: envVars.MONGODB_URL + (envVars.NODE_ENV === 'test' ? '-test' : ''), + options: { + useCreateIndex: true, + useNewUrlParser: true, + useUnifiedTopology: true, + }, + }, + jwt: { + secret: envVars.JWT_SECRET, + accessExpirationMinutes: envVars.JWT_ACCESS_EXPIRATION_MINUTES, + refreshExpirationDays: envVars.JWT_REFRESH_EXPIRATION_DAYS, + resetPasswordExpirationMinutes: envVars.JWT_RESET_PASSWORD_EXPIRATION_MINUTES, + verifyEmailExpirationMinutes: envVars.JWT_VERIFY_EMAIL_EXPIRATION_MINUTES, + }, + email: { + smtp: { + host: envVars.SMTP_HOST, + port: envVars.SMTP_PORT, + auth: { + user: envVars.SMTP_USERNAME, + pass: envVars.SMTP_PASSWORD, + }, + }, + from: envVars.EMAIL_FROM, + }, +}; diff --git a/src/config/logger.js b/src/config/logger.js new file mode 100644 index 0000000..bba9a7c --- /dev/null +++ b/src/config/logger.js @@ -0,0 +1,26 @@ +const winston = require('winston'); +const config = require('./config'); + +const enumerateErrorFormat = winston.format((info) => { + if (info instanceof Error) { + Object.assign(info, { message: info.stack }); + } + return info; +}); + +const logger = winston.createLogger({ + level: config.env === 'development' ? 'debug' : 'info', + format: winston.format.combine( + enumerateErrorFormat(), + config.env === 'development' ? winston.format.colorize() : winston.format.uncolorize(), + winston.format.splat(), + winston.format.printf(({ level, message }) => `${level}: ${message}`) + ), + transports: [ + new winston.transports.Console({ + stderrLevels: ['error'], + }), + ], +}); + +module.exports = logger; diff --git a/src/config/morgan.js b/src/config/morgan.js new file mode 100644 index 0000000..5239252 --- /dev/null +++ b/src/config/morgan.js @@ -0,0 +1,24 @@ +const morgan = require('morgan'); +const config = require('./config'); +const logger = require('./logger'); + +morgan.token('message', (req, res) => res.locals.errorMessage || ''); + +const getIpFormat = () => (config.env === 'production' ? ':remote-addr - ' : ''); +const successResponseFormat = `${getIpFormat()}:method :url :status - :response-time ms`; +const errorResponseFormat = `${getIpFormat()}:method :url :status - :response-time ms - message: :message`; + +const successHandler = morgan(successResponseFormat, { + skip: (req, res) => res.statusCode >= 400, + stream: { write: (message) => logger.info(message.trim()) }, +}); + +const errorHandler = morgan(errorResponseFormat, { + skip: (req, res) => res.statusCode < 400, + stream: { write: (message) => logger.error(message.trim()) }, +}); + +module.exports = { + successHandler, + errorHandler, +}; diff --git a/src/config/passport.js b/src/config/passport.js new file mode 100644 index 0000000..63efeaf --- /dev/null +++ b/src/config/passport.js @@ -0,0 +1,30 @@ +const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); +const config = require('./config'); +const { tokenTypes } = require('./tokens'); +const { User } = require('../models'); + +const jwtOptions = { + secretOrKey: config.jwt.secret, + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), +}; + +const jwtVerify = async (payload, done) => { + try { + if (payload.type !== tokenTypes.ACCESS) { + throw new Error('Invalid token type'); + } + const user = await User.findById(payload.sub); + if (!user) { + return done(null, false); + } + done(null, user); + } catch (error) { + done(error, false); + } +}; + +const jwtStrategy = new JwtStrategy(jwtOptions, jwtVerify); + +module.exports = { + jwtStrategy, +}; diff --git a/src/config/roles.js b/src/config/roles.js new file mode 100644 index 0000000..dcf42c7 --- /dev/null +++ b/src/config/roles.js @@ -0,0 +1,12 @@ +const allRoles = { + user: [], + admin: ['getUsers', 'manageUsers'], +}; + +const roles = Object.keys(allRoles); +const roleRights = new Map(Object.entries(allRoles)); + +module.exports = { + roles, + roleRights, +}; diff --git a/src/config/tokens.js b/src/config/tokens.js new file mode 100644 index 0000000..77a9976 --- /dev/null +++ b/src/config/tokens.js @@ -0,0 +1,10 @@ +const tokenTypes = { + ACCESS: 'access', + REFRESH: 'refresh', + RESET_PASSWORD: 'resetPassword', + VERIFY_EMAIL: 'verifyEmail', +}; + +module.exports = { + tokenTypes, +}; diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js new file mode 100644 index 0000000..89f2c1f --- /dev/null +++ b/src/controllers/auth.controller.js @@ -0,0 +1,59 @@ +const httpStatus = require('http-status'); +const catchAsync = require('../utils/catchAsync'); +const { authService, userService, tokenService, emailService } = require('../services'); + +const register = catchAsync(async (req, res) => { + const user = await userService.createUser(req.body); + const tokens = await tokenService.generateAuthTokens(user); + res.status(httpStatus.CREATED).send({ user, tokens }); +}); + +const login = catchAsync(async (req, res) => { + const { email, password } = req.body; + const user = await authService.loginUserWithEmailAndPassword(email, password); + const tokens = await tokenService.generateAuthTokens(user); + res.send({ user, tokens }); +}); + +const logout = catchAsync(async (req, res) => { + await authService.logout(req.body.refreshToken); + res.status(httpStatus.NO_CONTENT).send(); +}); + +const refreshTokens = catchAsync(async (req, res) => { + const tokens = await authService.refreshAuth(req.body.refreshToken); + res.send({ ...tokens }); +}); + +const forgotPassword = catchAsync(async (req, res) => { + const resetPasswordToken = await tokenService.generateResetPasswordToken(req.body.email); + await emailService.sendResetPasswordEmail(req.body.email, resetPasswordToken); + res.status(httpStatus.NO_CONTENT).send(); +}); + +const resetPassword = catchAsync(async (req, res) => { + await authService.resetPassword(req.query.token, req.body.password); + res.status(httpStatus.NO_CONTENT).send(); +}); + +const sendVerificationEmail = catchAsync(async (req, res) => { + const verifyEmailToken = await tokenService.generateVerifyEmailToken(req.user); + await emailService.sendVerificationEmail(req.user.email, verifyEmailToken); + res.status(httpStatus.NO_CONTENT).send(); +}); + +const verifyEmail = catchAsync(async (req, res) => { + await authService.verifyEmail(req.query.token); + res.status(httpStatus.NO_CONTENT).send(); +}); + +module.exports = { + register, + login, + logout, + refreshTokens, + forgotPassword, + resetPassword, + sendVerificationEmail, + verifyEmail, +}; diff --git a/src/controllers/index.js b/src/controllers/index.js new file mode 100644 index 0000000..653b4e2 --- /dev/null +++ b/src/controllers/index.js @@ -0,0 +1,2 @@ +module.exports.authController = require('./auth.controller'); +module.exports.userController = require('./user.controller'); diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js new file mode 100644 index 0000000..66b83fa --- /dev/null +++ b/src/controllers/user.controller.js @@ -0,0 +1,43 @@ +const httpStatus = require('http-status'); +const pick = require('../utils/pick'); +const ApiError = require('../utils/ApiError'); +const catchAsync = require('../utils/catchAsync'); +const { userService } = require('../services'); + +const createUser = catchAsync(async (req, res) => { + const user = await userService.createUser(req.body); + res.status(httpStatus.CREATED).send(user); +}); + +const getUsers = catchAsync(async (req, res) => { + const filter = pick(req.query, ['name', 'role']); + const options = pick(req.query, ['sortBy', 'limit', 'page']); + const result = await userService.queryUsers(filter, options); + res.send(result); +}); + +const getUser = catchAsync(async (req, res) => { + const user = await userService.getUserById(req.params.userId); + if (!user) { + throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); + } + res.send(user); +}); + +const updateUser = catchAsync(async (req, res) => { + const user = await userService.updateUserById(req.params.userId, req.body); + res.send(user); +}); + +const deleteUser = catchAsync(async (req, res) => { + await userService.deleteUserById(req.params.userId); + res.status(httpStatus.NO_CONTENT).send(); +}); + +module.exports = { + createUser, + getUsers, + getUser, + updateUser, + deleteUser, +}; diff --git a/src/docs/components.yml b/src/docs/components.yml new file mode 100644 index 0000000..37656e2 --- /dev/null +++ b/src/docs/components.yml @@ -0,0 +1,92 @@ +components: + schemas: + User: + type: object + properties: + id: + type: string + email: + type: string + format: email + name: + type: string + role: + type: string + enum: [user, admin] + example: + id: 5ebac534954b54139806c112 + email: fake@example.com + name: fake name + role: user + + Token: + type: object + properties: + token: + type: string + expires: + type: string + format: date-time + example: + token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1ZWJhYzUzNDk1NGI1NDEzOTgwNmMxMTIiLCJpYXQiOjE1ODkyOTg0ODQsImV4cCI6MTU4OTMwMDI4NH0.m1U63blB0MLej_WfB7yC2FTMnCziif9X8yzwDEfJXAg + expires: 2020-05-12T16:18:04.793Z + + AuthTokens: + type: object + properties: + access: + $ref: '#/components/schemas/Token' + refresh: + $ref: '#/components/schemas/Token' + + Error: + type: object + properties: + code: + type: number + message: + type: string + + responses: + DuplicateEmail: + description: Email already taken + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: 400 + message: Email already taken + Unauthorized: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: 401 + message: Please authenticate + Forbidden: + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: 403 + message: Forbidden + NotFound: + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: 404 + message: Not found + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT diff --git a/src/docs/swaggerDef.js b/src/docs/swaggerDef.js new file mode 100644 index 0000000..c1e1c1a --- /dev/null +++ b/src/docs/swaggerDef.js @@ -0,0 +1,21 @@ +const { version } = require('../../package.json'); +const config = require('../config/config'); + +const swaggerDef = { + openapi: '3.0.0', + info: { + title: 'node-express-boilerplate API documentation', + version, + license: { + name: 'MIT', + url: 'https://github.com/hagopj13/node-express-boilerplate/blob/master/LICENSE', + }, + }, + servers: [ + { + url: `http://localhost:${config.port}/v1`, + }, + ], +}; + +module.exports = swaggerDef; diff --git a/src/handlers/pingTest.js b/src/handlers/pingTest.js new file mode 100644 index 0000000..742492e --- /dev/null +++ b/src/handlers/pingTest.js @@ -0,0 +1,5 @@ +const homeHandler = (req, res) => { + res.send("Pong"); +}; + +export default homeHandler; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..bbfeb3f --- /dev/null +++ b/src/index.js @@ -0,0 +1,38 @@ +const mongoose = require('mongoose'); +const app = require('./app'); +const config = require('./config/config'); +const logger = require('./config/logger'); + +let server; +mongoose.connect(config.mongoose.url, config.mongoose.options).then(() => { + logger.info('Connected to MongoDB'); + server = app.listen(config.port, () => { + logger.info(`Listening to port ${config.port}`); + }); +}); + +const exitHandler = () => { + if (server) { + server.close(() => { + logger.info('Server closed'); + process.exit(1); + }); + } else { + process.exit(1); + } +}; + +const unexpectedErrorHandler = (error) => { + logger.error(error); + exitHandler(); +}; + +process.on('uncaughtException', unexpectedErrorHandler); +process.on('unhandledRejection', unexpectedErrorHandler); + +process.on('SIGTERM', () => { + logger.info('SIGTERM received'); + if (server) { + server.close(); + } +}); diff --git a/src/middlewares/auth.js b/src/middlewares/auth.js new file mode 100644 index 0000000..7392f55 --- /dev/null +++ b/src/middlewares/auth.js @@ -0,0 +1,31 @@ +const passport = require('passport'); +const httpStatus = require('http-status'); +const ApiError = require('../utils/ApiError'); +const { roleRights } = require('../config/roles'); + +const verifyCallback = (req, resolve, reject, requiredRights) => async (err, user, info) => { + if (err || info || !user) { + return reject(new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')); + } + req.user = user; + + if (requiredRights.length) { + const userRights = roleRights.get(user.role); + const hasRequiredRights = requiredRights.every((requiredRight) => userRights.includes(requiredRight)); + if (!hasRequiredRights && req.params.userId !== user.id) { + return reject(new ApiError(httpStatus.FORBIDDEN, 'Forbidden')); + } + } + + resolve(); +}; + +const auth = (...requiredRights) => async (req, res, next) => { + return new Promise((resolve, reject) => { + passport.authenticate('jwt', { session: false }, verifyCallback(req, resolve, reject, requiredRights))(req, res, next); + }) + .then(() => next()) + .catch((err) => next(err)); +}; + +module.exports = auth; diff --git a/src/middlewares/error.js b/src/middlewares/error.js new file mode 100644 index 0000000..df14e91 --- /dev/null +++ b/src/middlewares/error.js @@ -0,0 +1,44 @@ +const mongoose = require('mongoose'); +const httpStatus = require('http-status'); +const config = require('../config/config'); +const logger = require('../config/logger'); +const ApiError = require('../utils/ApiError'); + +const errorConverter = (err, req, res, next) => { + let error = err; + if (!(error instanceof ApiError)) { + const statusCode = + error.statusCode || error instanceof mongoose.Error ? httpStatus.BAD_REQUEST : httpStatus.INTERNAL_SERVER_ERROR; + const message = error.message || httpStatus[statusCode]; + error = new ApiError(statusCode, message, false, err.stack); + } + next(error); +}; + +// eslint-disable-next-line no-unused-vars +const errorHandler = (err, req, res, next) => { + let { statusCode, message } = err; + if (config.env === 'production' && !err.isOperational) { + statusCode = httpStatus.INTERNAL_SERVER_ERROR; + message = httpStatus[httpStatus.INTERNAL_SERVER_ERROR]; + } + + res.locals.errorMessage = err.message; + + const response = { + code: statusCode, + message, + ...(config.env === 'development' && { stack: err.stack }), + }; + + if (config.env === 'development') { + logger.error(err); + } + + res.status(statusCode).send(response); +}; + +module.exports = { + errorConverter, + errorHandler, +}; diff --git a/src/middlewares/rateLimiter.js b/src/middlewares/rateLimiter.js new file mode 100644 index 0000000..43804c0 --- /dev/null +++ b/src/middlewares/rateLimiter.js @@ -0,0 +1,11 @@ +const rateLimit = require('express-rate-limit'); + +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + skipSuccessfulRequests: true, +}); + +module.exports = { + authLimiter, +}; diff --git a/src/middlewares/validate.js b/src/middlewares/validate.js new file mode 100644 index 0000000..1407954 --- /dev/null +++ b/src/middlewares/validate.js @@ -0,0 +1,21 @@ +const Joi = require('joi'); +const httpStatus = require('http-status'); +const pick = require('../utils/pick'); +const ApiError = require('../utils/ApiError'); + +const validate = (schema) => (req, res, next) => { + const validSchema = pick(schema, ['params', 'query', 'body']); + const object = pick(req, Object.keys(validSchema)); + const { value, error } = Joi.compile(validSchema) + .prefs({ errors: { label: 'key' }, abortEarly: false }) + .validate(object); + + if (error) { + const errorMessage = error.details.map((details) => details.message).join(', '); + return next(new ApiError(httpStatus.BAD_REQUEST, errorMessage)); + } + Object.assign(req, value); + return next(); +}; + +module.exports = validate; diff --git a/src/models/index.js b/src/models/index.js new file mode 100644 index 0000000..35fd46b --- /dev/null +++ b/src/models/index.js @@ -0,0 +1,2 @@ +module.exports.Token = require('./token.model'); +module.exports.User = require('./user.model'); diff --git a/src/models/plugins/index.js b/src/models/plugins/index.js new file mode 100644 index 0000000..d85ebb8 --- /dev/null +++ b/src/models/plugins/index.js @@ -0,0 +1,2 @@ +module.exports.toJSON = require('./toJSON.plugin'); +module.exports.paginate = require('./paginate.plugin'); diff --git a/src/models/plugins/paginate.plugin.js b/src/models/plugins/paginate.plugin.js new file mode 100644 index 0000000..cbe3198 --- /dev/null +++ b/src/models/plugins/paginate.plugin.js @@ -0,0 +1,70 @@ +/* eslint-disable no-param-reassign */ + +const paginate = (schema) => { + /** + * @typedef {Object} QueryResult + * @property {Document[]} results - Results found + * @property {number} page - Current page + * @property {number} limit - Maximum number of results per page + * @property {number} totalPages - Total number of pages + * @property {number} totalResults - Total number of documents + */ + /** + * Query for documents with pagination + * @param {Object} [filter] - Mongo filter + * @param {Object} [options] - Query options + * @param {string} [options.sortBy] - Sorting criteria using the format: sortField:(desc|asc). Multiple sorting criteria should be separated by commas (,) + * @param {string} [options.populate] - Populate data fields. Hierarchy of fields should be separated by (.). Multiple populating criteria should be separated by commas (,) + * @param {number} [options.limit] - Maximum number of results per page (default = 10) + * @param {number} [options.page] - Current page (default = 1) + * @returns {Promise} + */ + schema.statics.paginate = async function (filter, options) { + let sort = ''; + if (options.sortBy) { + const sortingCriteria = []; + options.sortBy.split(',').forEach((sortOption) => { + const [key, order] = sortOption.split(':'); + sortingCriteria.push((order === 'desc' ? '-' : '') + key); + }); + sort = sortingCriteria.join(' '); + } else { + sort = 'createdAt'; + } + + const limit = options.limit && parseInt(options.limit, 10) > 0 ? parseInt(options.limit, 10) : 10; + const page = options.page && parseInt(options.page, 10) > 0 ? parseInt(options.page, 10) : 1; + const skip = (page - 1) * limit; + + const countPromise = this.countDocuments(filter).exec(); + let docsPromise = this.find(filter).sort(sort).skip(skip).limit(limit); + + if (options.populate) { + options.populate.split(',').forEach((populateOption) => { + docsPromise = docsPromise.populate( + populateOption + .split('.') + .reverse() + .reduce((a, b) => ({ path: b, populate: a })) + ); + }); + } + + docsPromise = docsPromise.exec(); + + return Promise.all([countPromise, docsPromise]).then((values) => { + const [totalResults, results] = values; + const totalPages = Math.ceil(totalResults / limit); + const result = { + results, + page, + limit, + totalPages, + totalResults, + }; + return Promise.resolve(result); + }); + }; +}; + +module.exports = paginate; diff --git a/src/models/plugins/toJSON.plugin.js b/src/models/plugins/toJSON.plugin.js new file mode 100644 index 0000000..d2cd22e --- /dev/null +++ b/src/models/plugins/toJSON.plugin.js @@ -0,0 +1,43 @@ +/* eslint-disable no-param-reassign */ + +/** + * A mongoose schema plugin which applies the following in the toJSON transform call: + * - removes __v, createdAt, updatedAt, and any path that has private: true + * - replaces _id with id + */ + +const deleteAtPath = (obj, path, index) => { + if (index === path.length - 1) { + delete obj[path[index]]; + return; + } + deleteAtPath(obj[path[index]], path, index + 1); +}; + +const toJSON = (schema) => { + let transform; + if (schema.options.toJSON && schema.options.toJSON.transform) { + transform = schema.options.toJSON.transform; + } + + schema.options.toJSON = Object.assign(schema.options.toJSON || {}, { + transform(doc, ret, options) { + Object.keys(schema.paths).forEach((path) => { + if (schema.paths[path].options && schema.paths[path].options.private) { + deleteAtPath(ret, path.split('.'), 0); + } + }); + + ret.id = ret._id.toString(); + delete ret._id; + delete ret.__v; + delete ret.createdAt; + delete ret.updatedAt; + if (transform) { + return transform(doc, ret, options); + } + }, + }); +}; + +module.exports = toJSON; diff --git a/src/models/token.model.js b/src/models/token.model.js new file mode 100644 index 0000000..9ff35e2 --- /dev/null +++ b/src/models/token.model.js @@ -0,0 +1,44 @@ +const mongoose = require('mongoose'); +const { toJSON } = require('./plugins'); +const { tokenTypes } = require('../config/tokens'); + +const tokenSchema = mongoose.Schema( + { + token: { + type: String, + required: true, + index: true, + }, + user: { + type: mongoose.SchemaTypes.ObjectId, + ref: 'User', + required: true, + }, + type: { + type: String, + enum: [tokenTypes.REFRESH, tokenTypes.RESET_PASSWORD, tokenTypes.VERIFY_EMAIL], + required: true, + }, + expires: { + type: Date, + required: true, + }, + blacklisted: { + type: Boolean, + default: false, + }, + }, + { + timestamps: true, + } +); + +// add plugin that converts mongoose to json +tokenSchema.plugin(toJSON); + +/** + * @typedef Token + */ +const Token = mongoose.model('Token', tokenSchema); + +module.exports = Token; diff --git a/src/models/user.model.js b/src/models/user.model.js new file mode 100644 index 0000000..185711c --- /dev/null +++ b/src/models/user.model.js @@ -0,0 +1,91 @@ +const mongoose = require('mongoose'); +const validator = require('validator'); +const bcrypt = require('bcryptjs'); +const { toJSON, paginate } = require('./plugins'); +const { roles } = require('../config/roles'); + +const userSchema = mongoose.Schema( + { + name: { + type: String, + required: true, + trim: true, + }, + email: { + type: String, + required: true, + unique: true, + trim: true, + lowercase: true, + validate(value) { + if (!validator.isEmail(value)) { + throw new Error('Invalid email'); + } + }, + }, + password: { + type: String, + required: true, + trim: true, + minlength: 8, + validate(value) { + if (!value.match(/\d/) || !value.match(/[a-zA-Z]/)) { + throw new Error('Password must contain at least one letter and one number'); + } + }, + private: true, // used by the toJSON plugin + }, + role: { + type: String, + enum: roles, + default: 'user', + }, + isEmailVerified: { + type: Boolean, + default: false, + }, + }, + { + timestamps: true, + } +); + +// add plugin that converts mongoose to json +userSchema.plugin(toJSON); +userSchema.plugin(paginate); + +/** + * Check if email is taken + * @param {string} email - The user's email + * @param {ObjectId} [excludeUserId] - The id of the user to be excluded + * @returns {Promise} + */ +userSchema.statics.isEmailTaken = async function (email, excludeUserId) { + const user = await this.findOne({ email, _id: { $ne: excludeUserId } }); + return !!user; +}; + +/** + * Check if password matches the user's password + * @param {string} password + * @returns {Promise} + */ +userSchema.methods.isPasswordMatch = async function (password) { + const user = this; + return bcrypt.compare(password, user.password); +}; + +userSchema.pre('save', async function (next) { + const user = this; + if (user.isModified('password')) { + user.password = await bcrypt.hash(user.password, 8); + } + next(); +}); + +/** + * @typedef User + */ +const User = mongoose.model('User', userSchema); + +module.exports = User; diff --git a/src/routes/api/apiTest.js b/src/routes/api/apiTest.js new file mode 100644 index 0000000..a48dfc0 --- /dev/null +++ b/src/routes/api/apiTest.js @@ -0,0 +1,17 @@ +const apiTest = (req, res) => { + // res.send(req.query.doa); //get + // res.send(req.body.doa); //post + const responseObject = { + message: 'Hello, this is your JSON response!', + success: true, + data: { + name: 'John Doe', + age: 30, + job: 'Developer' + } + }; + res.json(responseObject); + }; + +module.exports = apiTest + \ No newline at end of file diff --git a/src/routes/api/classMates.js b/src/routes/api/classMates.js new file mode 100644 index 0000000..cde9312 --- /dev/null +++ b/src/routes/api/classMates.js @@ -0,0 +1,149 @@ +const classMates = (req, res) => { + // res.send(req.query.doa); //get + // res.send(req.body.doa); //post + let classmatesData = [ + { + id: "1", + name: "Daniel Nguyen", + program: "Graduate Program", + type: "Student", + avatar: "/assets/avatar1.png" + }, + { + id: "2", + name: "Sarah Anderson", + program: "Post-Graduate Program", + type: "Student", + avatar: "/assets/avatar2.png" + }, + { + id: "3", + name: "John Smith", + program: "Undergraduate Program", + type: "Student", + avatar: "/assets/avatar3.png" + }, + { + id: "4", + name: "Emily Davis", + program: "Graduate Program", + type: "Student", + avatar: "/assets/avatar4.png" + }, + { + id: "5", + name: "Michael Johnson", + program: "Post-Graduate Program", + type: "Student", + avatar: "/assets/avatar5.png" + }, + { + id: "6", + name: "Jessica Wilson", + program: "Undergraduate Program", + type: "Student", + avatar: "/assets/avatar6.png" + }, + { + id: "7", + name: "David Brown", + program: "Graduate Program", + type: "Student", + avatar: "/assets/avatar1.png" + }, + { + id: "8", + name: "Laura Lee", + program: "Post-Graduate Program", + type: "Student", + avatar: "/assets/avatar2.png" + }, + { + id: "9", + name: "Chris Miller", + program: "Undergraduate Program", + type: "Student", + avatar: "/assets/avatar3.png" + }, + { + id: "10", + name: "Sophia Taylor", + program: "Graduate Program", + type: "Student", + avatar: "/assets/avatar4.png" + }, + { + id: "11", + name: "James Anderson", + program: "Post-Graduate Program", + type: "Student", + avatar: "/assets/avatar5.png" + }, + { + id: "12", + name: "Olivia Thomas", + program: "Undergraduate Program", + type: "Student", + avatar: "/assets/avatar6.png" + }, + { + id: "13", + name: "Ethan Martinez", + program: "Graduate Program", + type: "Student", + avatar: "/assets/avatar1.png" + }, + { + id: "14", + name: "Ava Garcia", + program: "Post-Graduate Program", + type: "Student", + avatar: "/assets/avatar2.png" + }, + { + id: "15", + name: "Noah Rodriguez", + program: "Undergraduate Program", + type: "Student", + avatar: "/assets/avatar3.png" + }, + { + id: "16", + name: "Mia Martinez", + program: "Graduate Program", + type: "Student", + avatar: "/assets/avatar4.png" + }, + { + id: "17", + name: "Lucas Wilson", + program: "Post-Graduate Program", + type: "Student", + avatar: "/assets/avatar5.png" + }, + { + id: "18", + name: "Isabella Clark", + program: "Undergraduate Program", + type: "Student", + avatar: "/assets/avatar6.png" + }, + { + id: "19", + name: "Liam Walker", + program: "Graduate Program", + type: "Student", + avatar: "/assets/avatar1.png" + }, + { + id: "20", + name: "Charlotte Lewis", + program: "Post-Graduate Program", + type: "Student", + avatar: "/assets/avatar2.png" + } + ]; + res.json(classmatesData); + }; + + module.exports = classMates; diff --git a/src/routes/api/continueLearning.js b/src/routes/api/continueLearning.js new file mode 100644 index 0000000..1aa7e71 --- /dev/null +++ b/src/routes/api/continueLearning.js @@ -0,0 +1,70 @@ +const mysql = require("mysql2"); +const continueLearning = (req, res) => { + // res.send(req.query.doa); //get + // res.send(req.body.doa); //post + const connection = mysql.createConnection({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: process.env.MARIA_DBNM + }); + + connection.connect((err) => { + if(err) { + console.error('Error connecting to the database:', err); + return; + } + console.log('Connected to the MariaDB database.'); + }); + + const data = req.body; + + const query = `SELECT * FROM continue_learning`; + connection.query(query, (err, results) => { + if (err) { + console.error('Error inserting data:', err); + res.status(500).send('Internal Server Error'); + return; + } + res.status(200).json(results); + }); + }; + + module.exports = continueLearning; + + + + + + + + + + + + + +// let courseData = [ + // { + // id : "1", + // title : "Life History of Dr. Maria Montessori", + // chapter : "1", + // Program : "Graduate Program", + // img : "/assets/course1.jpg" + // }, + // { + // id : "2", + // title : "Introduction to Montessori Methods", + // chapter : "2", + // Program : "Graduate Program", + // img : "/assets/course2.jpg" + // }, + // { + // id : "3", + // title : "Exercises on Practical Life", + // chapter : "3", + // Program : "Graduate Program", + // img : "/assets/course3.jpg" + // } + // ]; + // res.json(courseData); \ No newline at end of file diff --git a/src/routes/api/generateQuestions.js b/src/routes/api/generateQuestions.js new file mode 100644 index 0000000..f893c56 --- /dev/null +++ b/src/routes/api/generateQuestions.js @@ -0,0 +1,35 @@ +const mysql = require("mysql2"); + +const generateQuestions = (req, res) => { + const connection = mysql.createConnection({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: process.env.MARIA_DBNM + }); + + connection.connect((err) => { + if (err) { + console.error('Error connecting to the database:', err); + return; + } + console.log('Connected to the MariaDB database.'); + }); + + const { questions } = req.body; + res.send( questions ); + questions.forEach(question => { + const { questionText, options, correctAnswer } = question; + const sql = 'INSERT INTO quiz_questions (questionText, option1, option2, option3, option4, correctAnswer) VALUES (?, ?, ?, ?, ?, ?)'; + db.query(sql, [questionText, options[0], options[1], options[2], options[3], correctAnswer], (err, result) => { + if (err) { + console.error('Error inserting question:', err); + res.status(500).send('Error inserting question'); + return; + } + }); + }); + + res.status(200).send('Questions added successfully'); +} +module.exports = generateQuestions; \ No newline at end of file diff --git a/src/routes/api/getGameScore.js b/src/routes/api/getGameScore.js new file mode 100644 index 0000000..2ac4374 --- /dev/null +++ b/src/routes/api/getGameScore.js @@ -0,0 +1,32 @@ +const mysql = require("mysql2"); +const getGameScore = (req, res) => { + const pool = mysql.createPool({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: 'beanstalk_game', + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 + }); + const promisePool = pool.promise(); + const { userId, gameName, gameID } = req.body; + + const query = 'SELECT score, gameTime FROM gameData WHERE userId = ? AND gameName = ? AND gameID = ?'; + + pool.query(query, [userId, gameName, gameID], (error, results) => { + if (error) { + return res.status(500).json({ message: 'Database query failed', error }); + } + + if (results.length > 0) { + const game = results[0]; + res.json({ score: game.score, gameTime: game.gameTime }); + } else { + res.status(404).json({ message: 'Game data not found' }); + } + }); + }; + + module.exports = getGameScore; + \ No newline at end of file diff --git a/src/routes/api/knowledgeQuests.js b/src/routes/api/knowledgeQuests.js new file mode 100644 index 0000000..7906d03 --- /dev/null +++ b/src/routes/api/knowledgeQuests.js @@ -0,0 +1,63 @@ +const mysql = require("mysql2"); +const knowledgeQuests = (req, res) => { + // res.send(req.query.doa); //get + // res.send(req.body.doa); //post + const connection = mysql.createConnection({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: process.env.MARIA_DBNM + }); + + connection.connect((err) => { + if(err) { + console.error('Error connecting to the database:', err); + return; + } + console.log('Connected to the MariaDB database.'); + }); + + const data = req.body; + + const query = `SELECT * FROM knowledge_quests WHERE status = 1`; + connection.query(query, (err, results) => { + if (err) { + console.error('Error inserting data:', err); + res.status(500).send('Internal Server Error'); + return; + } + res.status(200).json(results); + }); +}; + +module.exports = knowledgeQuests; + + + +// let knowledgeData = [ +// { +// id: "1", +// status: "1", +// title: "Assessment on Special Education", +// challenge: "Challenge yourself & climb the leaderboard.", +// question: "Subjective Question", +// img: "/assets/knowledge1.jpg" +// }, +// { +// id: "2", +// status: "1", +// title: "Quiz on Children Psychology", +// challenge: "Challenge yourself & climb the leaderboard.", +// question: "MCQ", +// img: "/assets/knowledge2.jpg" +// }, +// { +// id: "3", +// status: "1", +// title: "Quiz on Montessori Methods", +// challenge: "Challenge yourself & climb the leaderboard.", +// question: "MCQ", +// img: "/assets/knowledge3.jpg" +// } +// ]; +// res.json(knowledgeData); \ No newline at end of file diff --git a/src/routes/api/knowledgeQuestsAllContent.js b/src/routes/api/knowledgeQuestsAllContent.js new file mode 100644 index 0000000..6c77b20 --- /dev/null +++ b/src/routes/api/knowledgeQuestsAllContent.js @@ -0,0 +1,180 @@ + +const knowledgeQuestsCompleted = (req, res) => { + // res.send(req.query.doa); //get + // res.send(req.body.doa); //post + let knowledgeCompleted = [ + { + id: "1", + status: "1", + title: "Assessment on Special Education", + challenge: "Challenge yourself & climb the leaderboard.", + question: "Subjective Question", + img: "/assets/knowledge1.jpg" + }, + { + id: "2", + status: "1", + title: "Quiz on Children Psychology", + challenge: "Challenge yourself & climb the leaderboard.", + question: "MCQ", + img: "/assets/knowledge2.jpg" + }, + { + id: "3", + status: "1", + title: "Quiz on Montessori Methods", + challenge: "Challenge yourself & climb the leaderboard.", + question: "MCQ", + img: "/assets/knowledge3.jpg" + }, + { + id: "4", + status: "1", + title: "Assessment on Special Education", + challenge: "Challenge yourself & climb the leaderboard.", + question: "Subjective Question", + img: "/assets/knowledge1.jpg" + }, + { + id: "5", + status: "1", + title: "Quiz on Children Psychology", + challenge: "Challenge yourself & climb the leaderboard.", + question: "MCQ", + img: "/assets/knowledge2.jpg" + }, + { + id: "6", + status: "1", + title: "Quiz on Montessori Methods", + challenge: "Challenge yourself & climb the leaderboard.", + question: "MCQ", + img: "/assets/knowledge3.jpg" + }, + { + id: "7", + status: "1", + title: "Workshop on Child Development", + challenge: "Expand your knowledge & earn badges.", + question: "Interactive Session", + img: "/assets/knowledge1.jpg" + }, + { + id: "8", + status: "1", + title: "Webinar on Educational Psychology", + challenge: "Join & enhance your skills.", + question: "Discussion", + img: "/assets/knowledge2.jpg" + }, + { + id: "9", + status: "1", + title: "Seminar on Inclusive Education", + challenge: "Participate & gain insights.", + question: "Lecture", + img: "/assets/knowledge3.jpg" + }, + { + id: "10", + status: "1", + title: "Course on Early Childhood Education", + challenge: "Complete the course & get certified.", + question: "Multiple Modules", + img: "/assets/knowledge1.jpg" + }, + { + id: "11", + status: "1", + title: "Training on Classroom Management", + challenge: "Improve your teaching strategies.", + question: "Practical Tasks", + img: "/assets/knowledge2.jpg" + }, + { + id: "12", + status: "1", + title: "Lecture on Cognitive Development", + challenge: "Expand your understanding & get certified.", + question: "Q&A Session", + img: "/assets/knowledge3.jpg" + }, + { + id: "13", + status: "1", + title: "Workshop on Behavioral Issues", + challenge: "Join & learn from experts.", + question: "Interactive Session", + img: "/assets/knowledge1.jpg" + }, + { + id: "14", + status: "1", + title: "Seminar on Learning Disabilities", + challenge: "Participate & enhance your knowledge.", + question: "Lecture", + img: "/assets/knowledge2.jpg" + }, + { + id: "15", + status: "1", + title: "Webinar on Child Psychology", + challenge: "Join & expand your skills.", + question: "Discussion", + img: "/assets/knowledge3.jpg" + }, + { + id: "16", + status: "1", + title: "Course on Special Education Needs", + challenge: "Complete the course & get certified.", + question: "Multiple Modules", + img: "/assets/knowledge1.jpg" + }, + { + id: "17", + status: "1", + title: "Training on Autism Spectrum Disorder", + challenge: "Improve your teaching strategies.", + question: "Practical Tasks", + img: "/assets/knowledge2.jpg" + }, + { + id: "18", + status: "1", + title: "Lecture on Emotional Development", + challenge: "Expand your understanding & get certified.", + question: "Q&A Session", + img: "/assets/knowledge3.jpg" + }, + { + id: "19", + status: "1", + title: "Workshop on ADHD", + challenge: "Join & learn from experts.", + question: "Interactive Session", + img: "/assets/knowledge1.jpg" + }, + { + id: "20", + status: "1", + title: "Seminar on Speech and Language Disorders", + challenge: "Participate & enhance your knowledge.", + question: "Lecture", + img: "/assets/knowledge2.jpg" + }, + { + id: "21", + status: "1", + title: "Webinar on Child Nutrition", + challenge: "Join & expand your skills.", + question: "Discussion", + img: "/assets/knowledge3.jpg" + } + ]; + res.json(knowledgeCompleted); + }; + + module.exports = knowledgeQuestsCompleted; + +// knowledgeQuestsAllContent \ No newline at end of file diff --git a/src/routes/api/knowledgeQuestsCompleted.js b/src/routes/api/knowledgeQuestsCompleted.js new file mode 100644 index 0000000..83c0c7e --- /dev/null +++ b/src/routes/api/knowledgeQuestsCompleted.js @@ -0,0 +1,180 @@ + +const knowledgeQuestsAllContent = (req, res) => { + // res.send(req.query.doa); //get + // res.send(req.body.doa); //post + let knowledgeData = [ + { + id: "1", + status: "1", + title: "Assessment on Special Education", + challenge: "Challenge yourself & climb the leaderboard.", + question: "Subjective Question", + img: "/assets/knowledge1.jpg" + }, + { + id: "2", + status: "1", + title: "Quiz on Children Psychology", + challenge: "Challenge yourself & climb the leaderboard.", + question: "MCQ", + img: "/assets/knowledge2.jpg" + }, + { + id: "3", + status: "1", + title: "Quiz on Montessori Methods", + challenge: "Challenge yourself & climb the leaderboard.", + question: "MCQ", + img: "/assets/knowledge3.jpg" + }, + { + id: "4", + status: "1", + title: "Assessment on Special Education", + challenge: "Challenge yourself & climb the leaderboard.", + question: "Subjective Question", + img: "/assets/knowledge1.jpg" + }, + { + id: "5", + status: "1", + title: "Quiz on Children Psychology", + challenge: "Challenge yourself & climb the leaderboard.", + question: "MCQ", + img: "/assets/knowledge2.jpg" + }, + { + id: "6", + status: "1", + title: "Quiz on Montessori Methods", + challenge: "Challenge yourself & climb the leaderboard.", + question: "MCQ", + img: "/assets/knowledge3.jpg" + }, + { + id: "7", + status: "1", + title: "Workshop on Child Development", + challenge: "Expand your knowledge & earn badges.", + question: "Interactive Session", + img: "/assets/knowledge1.jpg" + }, + { + id: "8", + status: "1", + title: "Webinar on Educational Psychology", + challenge: "Join & enhance your skills.", + question: "Discussion", + img: "/assets/knowledge2.jpg" + }, + { + id: "9", + status: "1", + title: "Seminar on Inclusive Education", + challenge: "Participate & gain insights.", + question: "Lecture", + img: "/assets/knowledge3.jpg" + }, + { + id: "10", + status: "1", + title: "Course on Early Childhood Education", + challenge: "Complete the course & get certified.", + question: "Multiple Modules", + img: "/assets/knowledge1.jpg" + }, + { + id: "11", + status: "1", + title: "Training on Classroom Management", + challenge: "Improve your teaching strategies.", + question: "Practical Tasks", + img: "/assets/knowledge2.jpg" + }, + { + id: "12", + status: "1", + title: "Lecture on Cognitive Development", + challenge: "Expand your understanding & get certified.", + question: "Q&A Session", + img: "/assets/knowledge3.jpg" + }, + { + id: "13", + status: "1", + title: "Workshop on Behavioral Issues", + challenge: "Join & learn from experts.", + question: "Interactive Session", + img: "/assets/knowledge1.jpg" + }, + { + id: "14", + status: "1", + title: "Seminar on Learning Disabilities", + challenge: "Participate & enhance your knowledge.", + question: "Lecture", + img: "/assets/knowledge2.jpg" + }, + { + id: "15", + status: "1", + title: "Webinar on Child Psychology", + challenge: "Join & expand your skills.", + question: "Discussion", + img: "/assets/knowledge3.jpg" + }, + { + id: "16", + status: "1", + title: "Course on Special Education Needs", + challenge: "Complete the course & get certified.", + question: "Multiple Modules", + img: "/assets/knowledge1.jpg" + }, + { + id: "17", + status: "1", + title: "Training on Autism Spectrum Disorder", + challenge: "Improve your teaching strategies.", + question: "Practical Tasks", + img: "/assets/knowledge2.jpg" + }, + { + id: "18", + status: "1", + title: "Lecture on Emotional Development", + challenge: "Expand your understanding & get certified.", + question: "Q&A Session", + img: "/assets/knowledge3.jpg" + }, + { + id: "19", + status: "1", + title: "Workshop on ADHD", + challenge: "Join & learn from experts.", + question: "Interactive Session", + img: "/assets/knowledge1.jpg" + }, + { + id: "20", + status: "1", + title: "Seminar on Speech and Language Disorders", + challenge: "Participate & enhance your knowledge.", + question: "Lecture", + img: "/assets/knowledge2.jpg" + }, + { + id: "21", + status: "1", + title: "Webinar on Child Nutrition", + challenge: "Join & expand your skills.", + question: "Discussion", + img: "/assets/knowledge3.jpg" + } + ]; + res.json(knowledgeData); + }; + + module.exports = knowledgeQuestsAllContent; + +// knowledgeQuestsAllContent \ No newline at end of file diff --git a/src/routes/api/newQuestion.js b/src/routes/api/newQuestion.js new file mode 100644 index 0000000..69689c7 --- /dev/null +++ b/src/routes/api/newQuestion.js @@ -0,0 +1,34 @@ +const mysql = require("mysql2"); + +const newQuestion = (req, res) => { + const connection = mysql.createConnection({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: process.env.MARIA_DBNM + }); + + connection.connect((err) => { + if (err) { + console.error('Error connecting to the database:', err); + return; + } + console.log('Connected to the MariaDB database.'); + }); + + const data = req.body; + + const query = `INSERT INTO quiz_questions (questionText, option1, option2, option3, option4, correctAnswer, moduleId) VALUES (?, ?, ?, ?, ?, ?, ?)`; + const values = [data.question, data.option1, data.option2, data.option3, data.option4, data.correctAnswer, data.moduleId]; + + connection.query(query, values, (err, results) => { + if (err) { + console.error('Error inserting data:', err); + res.status(500).send('Internal Server Error'); + return; + } + res.status(200).json(results); + }); +} +module.exports = newQuestion +; \ No newline at end of file diff --git a/src/routes/api/newQuiz.js b/src/routes/api/newQuiz.js new file mode 100644 index 0000000..f6a344e --- /dev/null +++ b/src/routes/api/newQuiz.js @@ -0,0 +1,34 @@ +const mysql = require("mysql2"); + +const newQuiz = (req, res) => { + const connection = mysql.createConnection({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: process.env.MARIA_DBNM + }); + + connection.connect((err) => { + if (err) { + console.error('Error connecting to the database:', err); + return; + } + console.log('Connected to the MariaDB database.'); + }); + + const data = req.body; + + const query = `INSERT INTO quiz (moduleName, type) VALUES (?, ?)`; + const values = [data.moduleName, data.moduleType]; + + connection.query(query, values, (err, results) => { + if (err) { + console.error('Error inserting data:', err); + res.status(500).send('Internal Server Error'); + return; + } + res.status(200).json(results); + }); +} +module.exports = newQuiz +; \ No newline at end of file diff --git a/src/routes/api/questionList.js b/src/routes/api/questionList.js new file mode 100644 index 0000000..9ce2e67 --- /dev/null +++ b/src/routes/api/questionList.js @@ -0,0 +1,182 @@ +const mysql = require("mysql2"); +const questionList = (req, res) => { + // res.send(req.query.doa); //get + // res.send(req.body.doa); //post + function generateUniqueId() { + let timestamp = new Date().getTime().toString(); + let randomNumber = Math.floor(Math.random() * 100000).toString(); + let uniqueId = timestamp + randomNumber; + + if (uniqueId.length > 10) { + uniqueId = uniqueId.substring(0, 10); + } else if (uniqueId.length < 10) { + while (uniqueId.length < 10) { + uniqueId += Math.floor(Math.random() * 10).toString(); + } + } + + return uniqueId; + } + + const connection = mysql.createConnection({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: process.env.MARIA_DBNM + }); + + connection.connect((err) => { + if(err) { + console.error('Error connecting to the database:', err); + return; + } + console.log('Connected to the MariaDB database.'); + }); + + const data = req.body; + + const query = `SELECT * FROM quiz_questions LIMIT 6`; + connection.query(query, (err, results) => { + if (err) { + console.error('Error inserting data:', err); + res.status(500).send('Internal Server Error'); + return; + } + const uniqueId = generateUniqueId(); + results.forEach(obj => { + obj.newQuizId = uniqueId + }) + + + res.status(200).json(results); + }); +}; + +module.exports = questionList; +// { +// id: 1, +// question: "What is the capital of France?", +// options: ["Berlin", "Madrid", "Paris", "Rome"], +// answer: "Paris", +// }, +// { +// id: 2, +// question: "Which planet is known as the Red Planet?", +// options: ["Earth", "Mars", "Jupiter", "Saturn"], +// answer: "Mars", +// }, +// { +// id: 3, +// question: "What is the chemical symbol for gold?", +// options: ["Au", "Ag", "Pb", "Fe"], +// answer: "Au", +// }, +// { +// id: 4, +// question: "Who wrote 'To Kill a Mockingbird'?", +// options: ["Harper Lee", "Mark Twain", "Ernest Hemingway", "J.K. Rowling"], +// answer: "Harper Lee", +// }, +// { +// id: 5, +// question: "What is the largest ocean on Earth?", +// options: ["Atlantic Ocean", "Indian Ocean", "Arctic Ocean", "Pacific Ocean"], +// answer: "Pacific Ocean", +// }, +// { +// id: 6, +// question: "Which element has the atomic number 1?", +// options: ["Helium", "Hydrogen", "Oxygen", "Carbon"], +// answer: "Hydrogen", +// }, +// { +// id: 7, +// question: "In which year did the Titanic sink?", +// options: ["1912", "1905", "1898", "1923"], +// answer: "1912", +// }, +// { +// id: 8, +// question: "Who is the author of '1984'?", +// options: ["George Orwell", "Aldous Huxley", "Ray Bradbury", "J.D. Salinger"], +// answer: "George Orwell", +// }, +// { +// id: 9, +// question: "What is the hardest natural substance on Earth?", +// options: ["Gold", "Platinum", "Diamond", "Iron"], +// answer: "Diamond", +// }, +// { +// id: 10, +// question: "What is the largest planet in our solar system?", +// options: ["Earth", "Saturn", "Neptune", "Jupiter"], +// answer: "Jupiter", +// }, +// { +// id: 11, +// question: "What is the main ingredient in guacamole?", +// options: ["Tomato", "Avocado", "Pepper", "Onion"], +// answer: "Avocado", +// }, +// { +// id: 12, +// question: "Which country is known as the Land of the Rising Sun?", +// options: ["China", "Japan", "Thailand", "South Korea"], +// answer: "Japan", +// }, +// { +// id: 13, +// question: "What is the smallest prime number?", +// options: ["1", "2", "3", "5"], +// answer: "2", +// }, +// { +// id: 14, +// question: "Who painted the Mona Lisa?", +// options: ["Vincent van Gogh", "Leonardo da Vinci", "Pablo Picasso", "Claude Monet"], +// answer: "Leonardo da Vinci", +// }, +// { +// id: 15, +// question: "What is the capital city of Australia?", +// options: ["Sydney", "Melbourne", "Canberra", "Brisbane"], +// answer: "Canberra", +// }, +// { +// id: 16, +// question: "Which gas do plants primarily use for photosynthesis?", +// options: ["Oxygen", "Nitrogen", "Carbon Dioxide", "Hydrogen"], +// answer: "Carbon Dioxide", +// }, +// { +// id: 17, +// question: "What is the boiling point of water in Celsius?", +// options: ["90°C", "100°C", "110°C", "120°C"], +// answer: "100°C", +// }, +// { +// id: 18, +// question: "Which language is primarily spoken in Brazil?", +// options: ["Spanish", "Portuguese", "French", "English"], +// answer: "Portuguese", +// }, +// { +// id: 19, +// question: "What is the smallest unit of life?", +// options: ["Tissue", "Organ", "Cell", "Organism"], +// answer: "Cell", +// }, +// { +// id: 20, +// question: "Who developed the theory of relativity?", +// options: ["Isaac Newton", "Galileo Galilei", "Albert Einstein", "Niels Bohr"], +// answer: "Albert Einstein", +// }, +// { +// id: 21, +// question: "In what year did World War II end?", +// options: ["1945", "1944", "1946", "1943"], +// answer: "1945", +// }, +// ]; \ No newline at end of file diff --git a/src/routes/api/quizList.js b/src/routes/api/quizList.js new file mode 100644 index 0000000..2ad4b04 --- /dev/null +++ b/src/routes/api/quizList.js @@ -0,0 +1,33 @@ +const mysql = require("mysql2"); +const quizList = (req, res) => { + // res.send(req.query.doa); //get + // res.send(req.body.doa); //post + const connection = mysql.createConnection({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: process.env.MARIA_DBNM + }); + + connection.connect((err) => { + if(err) { + console.error('Error connecting to the database:', err); + return; + } + console.log('Connected to the MariaDB database.'); + }); + + const data = req.body; + + const query = `SELECT * FROM quiz`; + connection.query(query, (err, results) => { + if (err) { + console.error('Error inserting data:', err); + res.status(500).send('Internal Server Error'); + return; + } + res.status(200).json(results); + }); +}; + +module.exports = quizList; \ No newline at end of file diff --git a/src/routes/api/quizModuleData.js b/src/routes/api/quizModuleData.js new file mode 100644 index 0000000..a32cb23 --- /dev/null +++ b/src/routes/api/quizModuleData.js @@ -0,0 +1,419 @@ +const mysql = require("mysql2"); +const quizModuleData = (req, res) => { + // res.send(req.query.doa); //get + // res.send(req.body.doa); //post + + + // const connection = mysql.createConnection({ + // host: process.env.MARIA_HOST, + // user: process.env.MARIA_USER, + // password: process.env.MARIA_PASS, + // database: process.env.MARIA_DBNM + // }); + + // connection.connect((err) => { + // if(err) { + // console.error('Error connecting to the database:', err); + // return; + // } + // console.log('Connected to the MariaDB database.'); + // }); + + // const data = req.body; + + // const query = `SELECT * FROM quiz_questions`; + // connection.query(query, (err, results) => { + // if (err) { + // console.error('Error inserting data:', err); + // res.status(500).send('Internal Server Error'); + // return; + // } + // res.status(200).json(results); + // }); + + let quizModuleData = { + modules: [ + { + moduleId: 1, + type: "Theory Quiz Scores", + moduleName: "Module 1 - Life History of Dr. Maria Montessori", + quizzes: [ + { + quizId: 1, + quizName: "Lorem Ipsum Dolor Sit", + attendQuestion: 48, + totalQuestion: 50, + internalMarks: 28, + attendance: 20, + questions: [ + { + questionId: 1, + questionText: "What is the capital of France?", + options: [ + "Paris", + "London", + "Berlin", + "Madrid" + ], + correctAnswer: "Paris" + }, + { + questionId: 2, + questionText: "What is 2 + 2?", + options: [ + "3", + "4", + "5", + "6" + ], + correctAnswer: "4" + }, + { + questionId: 3, + questionText: "What is the boiling point of water?", + options: [ + "90°C", + "100°C", + "110°C", + "120°C" + ], + correctAnswer: "100°C" + }, + { + questionId: 4, + questionText: "Who wrote 'To Kill a Mockingbird'?", + options: [ + "Harper Lee", + "Mark Twain", + "J.K. Rowling", + "Ernest Hemingway" + ], + correctAnswer: "Harper Lee" + }, + { + questionId: 5, + questionText: "What is the largest planet in our solar system?", + options: [ + "Earth", + "Mars", + "Jupiter", + "Saturn" + ], + correctAnswer: "Jupiter" + }, + { + questionId: 6, + questionText: "What is the speed of light?", + options: [ + "300,000 km/s", + "150,000 km/s", + "100,000 km/s", + "50,000 km/s" + ], + correctAnswer: "300,000 km/s" + }, + { + questionId: 7, + questionText: "Who painted the Mona Lisa?", + options: [ + "Vincent van Gogh", + "Pablo Picasso", + "Leonardo da Vinci", + "Claude Monet" + ], + correctAnswer: "Leonardo da Vinci" + }, + { + questionId: 8, + questionText: "What is the chemical symbol for gold?", + options: [ + "Au", + "Ag", + "Pt", + "Pb" + ], + correctAnswer: "Au" + }, + { + questionId: 9, + questionText: "What is the tallest mountain in the world?", + options: [ + "K2", + "Kangchenjunga", + "Mount Everest", + "Lhotse" + ], + correctAnswer: "Mount Everest" + }, + { + questionId: 10, + questionText: "What is the smallest unit of life?", + options: [ + "Cell", + "Atom", + "Molecule", + "Organ" + ], + correctAnswer: "Cell" + } + ] + }, + { + quizId: 2, + quizName: "Lorem Ipsum Dolor Sit", + attendQuestion: 45, + totalQuestion: 50, + internalMarks: 29, + attendance: 20, + questions: [ + { + questionId: 1, + questionText: "What is the capital of Italy?", + options: [ + "Rome", + "Venice", + "Florence", + "Milan" + ], + correctAnswer: "Rome" + }, + { + questionId: 2, + questionText: "What is 3 + 5?", + options: [ + "7", + "8", + "9", + "10" + ], + correctAnswer: "8" + }, + { + questionId: 3, + questionText: "What is the freezing point of water?", + options: [ + "0°C", + "32°C", + "100°C", + "273K" + ], + correctAnswer: "0°C" + }, + { + questionId: 4, + questionText: "Who wrote 'Pride and Prejudice'?", + options: [ + "Jane Austen", + "Charles Dickens", + "Emily Brontë", + "George Eliot" + ], + correctAnswer: "Jane Austen" + }, + { + questionId: 5, + questionText: "What is the smallest planet in our solar system?", + options: [ + "Mercury", + "Venus", + "Earth", + "Mars" + ], + correctAnswer: "Mercury" + }, + { + questionId: 6, + questionText: "What is the speed of sound?", + options: [ + "343 m/s", + "300 m/s", + "1500 m/s", + "1000 m/s" + ], + correctAnswer: "343 m/s" + }, + { + questionId: 7, + questionText: "Who painted the Starry Night?", + options: [ + "Vincent van Gogh", + "Pablo Picasso", + "Leonardo da Vinci", + "Claude Monet" + ], + correctAnswer: "Vincent van Gogh" + }, + { + questionId: 8, + questionText: "What is the chemical symbol for silver?", + options: [ + "Au", + "Ag", + "Pt", + "Pb" + ], + correctAnswer: "Ag" + }, + { + questionId: 9, + questionText: "What is the second tallest mountain in the world?", + options: [ + "K2", + "Kangchenjunga", + "Mount Everest", + "Lhotse" + ], + correctAnswer: "K2" + }, + { + questionId: 10, + questionText: "What is the largest organ in the human body?", + options: [ + "Liver", + "Heart", + "Skin", + "Brain" + ], + correctAnswer: "Skin" + } + ] + } + ] + }, + { + moduleId: 2, + type: "Theory Quiz Scores", + moduleName: "Module 2", + attendQuestion: 42, + totalQuestion: 50, + internalMarks: 22, + attendance: 20, + quizzes: [ + { + quizId: 1, + quizName: "Quiz 1", + attendQuestion: 49, + totalQuestion: 50, + internalMarks: 2, + attendance: 20, + questions: [ + { + questionId: 1, + questionText: "What is the capital of Germany?", + options: [ + "Berlin", + "Munich", + "Hamburg", + "Frankfurt" + ], + correctAnswer: "Berlin" + }, + { + questionId: 2, + questionText: "What is 5 + 3?", + options: [ + "7", + "8", + "9", + "10" + ], + correctAnswer: "8" + }, + { + questionId: 3, + questionText: "What is the melting point of ice?", + options: [ + "0°C", + "32°C", + "100°C", + "273K" + ], + correctAnswer: "0°C" + }, + { + questionId: 4, + questionText: "Who wrote '1984'?", + options: [ + "George Orwell", + "Aldous Huxley", + "Ray Bradbury", + "J.D. Salinger" + ], + correctAnswer: "George Orwell" + }, + { + questionId: 5, + questionText: "What is the second smallest planet in our solar system?", + options: [ + "Mercury", + "Venus", + "Earth", + "Mars" + ], + correctAnswer: "Mars" + }, + { + questionId: 6, + questionText: "What is the speed of light in a vacuum?", + options: [ + "300,000 km/s", + "150,000 km/s", + "299,792 km/s", + "299,792 m/s" + ], + correctAnswer: "299,792 km/s" + }, + { + questionId: 7, + questionText: "Who painted the Last Supper?", + options: [ + "Vincent van Gogh", + "Pablo Picasso", + "Leonardo da Vinci", + "Claude Monet" + ], + correctAnswer: "Leonardo da Vinci" + }, + { + questionId: 8, + questionText: "What is the chemical symbol for iron?", + options: [ + "Fe", + "Ir", + "In", + "I" + ], + correctAnswer: "Fe" + }, + { + questionId: 9, + questionText: "What is the third tallest mountain in the world?", + options: [ + "K2", + "Kangchenjunga", + "Mount Everest", + "Lhotse" + ], + correctAnswer: "Kangchenjunga" + }, + { + questionId: 10, + questionText: "What is the smallest bone in the human body?", + options: [ + "Stapes", + "Femur", + "Tibia", + "Fibula" + ], + correctAnswer: "Stapes" + } + ] + } + ] + } + ] + } + res.json(quizModuleData); + }; + +module.exports = quizModuleData; \ No newline at end of file diff --git a/src/routes/api/quizModuleList copy.js b/src/routes/api/quizModuleList copy.js new file mode 100644 index 0000000..fd6b2d5 --- /dev/null +++ b/src/routes/api/quizModuleList copy.js @@ -0,0 +1,34 @@ +const mysql = require("mysql2"); +const quizModuleList = (req, res) => { + // res.send(req.query.doa); //get + // res.send(req.body.doa); //post + const connection = mysql.createConnection({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: process.env.MARIA_DBNM + }); + + connection.connect((err) => { + if(err) { + console.error('Error connecting to the database:', err); + return; + } + console.log('Connected to the MariaDB database.'); + }); + + const data = req.body; + + const query = `SELECT * FROM quiz_modules WHERE moduleId = ?`; + const values = req.query.module_id; + connection.query(query, (err, results) => { + if (err) { + console.error('Error inserting data:', err); + res.status(500).send('Internal Server Error'); + return; + } + res.status(200).json(results); + }); +}; + +module.exports = quizModuleList; \ No newline at end of file diff --git a/src/routes/api/quizModuleList.js b/src/routes/api/quizModuleList.js new file mode 100644 index 0000000..8ee9fec --- /dev/null +++ b/src/routes/api/quizModuleList.js @@ -0,0 +1,34 @@ +const mysql = require("mysql2"); +const quizModuleList = (req, res) => { + // res.send(req.query.doa); //get + // res.send(req.body.doa); //post + const connection = mysql.createConnection({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: process.env.MARIA_DBNM + }); + + connection.connect((err) => { + if(err) { + console.error('Error connecting to the database:', err); + return; + } + console.log('Connected to the MariaDB database.'); + }); + + const data = req.body; + let values = req.query.module_id ? req.query.module_id : ''; + const query = `SELECT * FROM quiz_modules WHERE moduleId = ?`; + // const values = req.query.module_id; + connection.query(query, values, (err, results) => { + if (err) { + console.error('Error inserting data:', err); + res.status(500).send('Internal Server Error'); + return; + } + res.status(200).json(results); + }); +}; + +module.exports = quizModuleList; \ No newline at end of file diff --git a/src/routes/api/quizNewModule.js b/src/routes/api/quizNewModule.js new file mode 100644 index 0000000..e2973d9 --- /dev/null +++ b/src/routes/api/quizNewModule.js @@ -0,0 +1,33 @@ +const mysql = require("mysql2"); + +const quizNewModule = (req, res) => { + const connection = mysql.createConnection({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: process.env.MARIA_DBNM + }); + + connection.connect((err) => { + if (err) { + console.error('Error connecting to the database:', err); + return; + } + console.log('Connected to the MariaDB database.'); + }); + + const data = req.body; + + const query = `INSERT INTO quiz_modules (moduleName, type) VALUES (?, ?)`; + const values = [data.moduleName, data.moduleType]; + + connection.query(query, values, (err, results) => { + if (err) { + console.error('Error inserting data:', err); + res.status(500).send('Internal Server Error'); + return; + } + res.status(200).json(results); + }); +} +module.exports = quizNewModule; \ No newline at end of file diff --git a/src/routes/api/quizzesScore.js b/src/routes/api/quizzesScore.js new file mode 100644 index 0000000..5366ad1 --- /dev/null +++ b/src/routes/api/quizzesScore.js @@ -0,0 +1,176 @@ +const mysql = require("mysql2"); +const quizzesScore = (req, res) => { + // res.send(req.query.doa); //get + // res.send(req.body.doa); //post + + const connection = mysql.createConnection({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: process.env.MARIA_DBNM + }); + + connection.connect((err) => { + if(err) { + console.error('Error connecting to the database:', err); + return; + } + console.log('Connected to the MariaDB database.'); + }); + + const data = req.body; + + const query = `SELECT * FROM quiz_score`; + connection.query(query, (err, results) => { + if (err) { + console.error('Error inserting data:', err); + res.status(500).send('Internal Server Error'); + return; + } + res.status(200).json(results); + }); + + }; + + module.exports = quizzesScore; + + + + + + + + + + + + + + + + + + +// let quizData = [ +// { +// quizId: 1, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 1", +// percentage: "60" +// }, +// { +// quizId: 2, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 2", +// percentage: "75" +// }, +// { +// quizId: 3, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 3", +// percentage: "80" +// }, +// { +// quizId: 4, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 4", +// percentage: "65" +// }, +// { +// quizId: 5, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 5", +// percentage: "70" +// }, +// { +// quizId: 6, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 6", +// percentage: "85" +// }, +// { +// quizId: 7, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 7", +// percentage: "90" +// }, +// { +// quizId: 8, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 8", +// percentage: "95" +// }, +// { +// quizId: 9, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 9", +// percentage: "88" +// }, +// { +// quizId: 10, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 10", +// percentage: "92" +// }, +// { +// quizId: 11, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 11", +// percentage: "77" +// }, +// { +// quizId: 12, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 12", +// percentage: "82" +// }, +// { +// quizId: 13, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 13", +// percentage: "68" +// }, +// { +// quizId: 14, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 14", +// percentage: "73" +// }, +// { +// quizId: 15, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 15", +// percentage: "79" +// }, +// { +// quizId: 16, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 16", +// percentage: "87" +// }, +// { +// quizId: 17, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 17", +// percentage: "93" +// }, +// { +// quizId: 18, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 18", +// percentage: "67" +// }, +// { +// quizId: 19, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 19", +// percentage: "89" +// }, +// { +// quizId: 20, +// quizType: "AI Quiz", +// quizName: "Assessment on Special Education - 20", +// percentage: "91" +// } +// ] +// res.json(quizData); \ No newline at end of file diff --git a/src/routes/api/resultAfterQuizSubmit.js b/src/routes/api/resultAfterQuizSubmit.js new file mode 100644 index 0000000..ae0bd77 --- /dev/null +++ b/src/routes/api/resultAfterQuizSubmit.js @@ -0,0 +1,93 @@ +const mysql = require("mysql2"); + +const resultAfterQuizSubmit = (req, res) => { + const connection = mysql.createConnection({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: process.env.MARIA_DBNM + }); + + connection.connect((err) => { + if (err) { + console.error('Error connecting to the database:', err); + res.status(500).send('Internal Server Error'); + return; + } + console.log('Connected to the MariaDB database.'); + }); + + const queryData = req.query; + const responseValues = [queryData.id]; // Ensure this is an array + + const responseQuery = `SELECT * FROM quiz_response WHERE quizId = ?`; + connection.query(responseQuery, responseValues, (err, results) => { + if (err) { + console.error('Error retrieving data:', err); + res.status(500).send('Internal Server Error'); + connection.end(); + return; + } + + let questionsProcessed = 0; + let allResults = []; + + results.forEach((resultData, index) => { + const questionId = [resultData.questionId]; // Ensure this is an array + const answerQuery = `SELECT * FROM quiz_questions WHERE questionId = ?`; + + connection.query(answerQuery, questionId, (err, questionResults) => { + if (err) { + console.error('Error retrieving question data:', err); + res.status(500).send('Internal Server Error'); + connection.end(); + return; + } + + allResults.push({ + response: resultData, + question: questionResults[0] // Assuming it returns one result per questionId + }); + + questionsProcessed++; + + if (questionsProcessed === results.length) { + let correctAnswers = 0; + allResults.forEach(item => { + if (item.response.selectedOption === item.question.correctAnswer) { + correctAnswers++; + } + }); + + let quizMessage; + + if(correctAnswers > 3){ + quizMessage = 'Congratulations on your achievement.' + }else if(correctAnswers < 3){ + quizMessage = 'You not paassed the quiz Try Again' + } + + res.status(200).json({ + totalQuestions: results.length, + correctAnswers: correctAnswers, + message: quizMessage, + details: allResults + }); + + connection.end(); + } + }); + }); + + if (results.length === 0) { + res.status(200).json({ + totalQuestions: 0, + correctAnswers: 0, + details: [] + }); + connection.end(); + } + }); +}; + +module.exports = resultAfterQuizSubmit; diff --git a/src/routes/api/saveGameScore.js b/src/routes/api/saveGameScore.js new file mode 100644 index 0000000..7343e0d --- /dev/null +++ b/src/routes/api/saveGameScore.js @@ -0,0 +1,66 @@ +var MongoClient = require('mongodb').MongoClient; +const AWS = require('aws-sdk'); + +const saveGameScore = (req, res) => { + const url = process.env.MONGODB_URL; + const dbName = process.env.MONGO_DB_NAME; + const client = new MongoClient(url, { useUnifiedTopology: true }); + client.connect((err) => { + if (err) { + console.error('Failed to connect to the server', err); + return; + } + // console.log('Connected successfully to server'); + const db = client.db(dbName); + const collection = db.collection('gameData'); + // const data = req.body; + const { userId, gameName, gameID, gameTime, score, screenShot } = req.body; + const data = { + userId: userId, + gameName: gameName, + gameID: gameID + }; + + collection.insertOne(data, (err, result) => { + if (err) { + console.error('Failed to insert document', err); + } else { + // console.log('Document inserted with _id: ', result.insertedId); + } + client.close((err) => { + if (err) { + console.error('Failed to close connection', err); + } else { + // console.log('Connection closed'); + const s3 = new AWS.S3({ + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + region: process.env.AWS_REGION + }); + if (screenShot != undefined) { + // Upload image to S3 + let base64Image = screenShot.split(";base64,").pop(); + const buffer = Buffer.from(base64Image, 'base64'); + const s3Params = { + Bucket: process.env.S3_BUCKET_NAME, + Key: `images/${result.insertedId}.png`, // Change the file extension to .png + Body: buffer, + ContentEncoding: 'base64', + ContentType: 'image/jpeg' // Change the content type to image/png + }; + try { + const data = s3.upload(s3Params).promise(); + console.log(`File uploaded successfully at ${data.Location}`); + } catch (err) { + console.error(err); + } + }; + res.send(result.insertedId); + } + }); + }); + }); + }; + + module.exports = saveGameScore; + \ No newline at end of file diff --git a/src/routes/api/savePostData.js b/src/routes/api/savePostData.js new file mode 100644 index 0000000..c7f87cb --- /dev/null +++ b/src/routes/api/savePostData.js @@ -0,0 +1,33 @@ +const mysql = require("mysql2"); + +const savePostData = (req, res) => { + const connection = mysql.createConnection({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: process.env.MARIA_DBNM + }); + + connection.connect((err) => { + if (err) { + console.error('Error connecting to the database:', err); + return; + } + console.log('Connected to the MariaDB database.'); + }); + + const data = req.body; + + const query = `INSERT INTO tst (name, email) VALUES (?, ?)`; + const values = [data.name, data.email]; + + connection.query(query, values, (err, results) => { + if (err) { + console.error('Error inserting data:', err); + res.status(500).send('Internal Server Error'); + return; + } + res.status(200).send('Data inserted successfully'); + }); +} +module.exports = savePostData; \ No newline at end of file diff --git a/src/routes/api/saveQuizResponse.js b/src/routes/api/saveQuizResponse.js new file mode 100644 index 0000000..8f02f6f --- /dev/null +++ b/src/routes/api/saveQuizResponse.js @@ -0,0 +1,59 @@ +const mysql = require("mysql2"); + +const saveQuizResponse = (req, res) => { + const connection = mysql.createConnection({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: process.env.MARIA_DBNM + }); + + connection.connect((err) => { + if (err) { + console.error('Error connecting to the database:', err); + res.status(500).send('Internal Server Error'); + return; + } + console.log('Connected to the MariaDB database.'); + }); + + const data = req.body; + // console.log(data); + + const query = `INSERT INTO quiz_response (questionId, quizId, selectedOption) VALUES (?, ?, ?)`; + + let errorOccurred = false; + let promises = []; + + data.forEach((response) => { + const values = [response.questionId, response.quizId, response.selectedOption]; + const promise = new Promise((resolve, reject) => { + connection.query(query, values, (err, results) => { + if (err) { + console.error('Error inserting data:', err); + errorOccurred = true; + reject(err); + } else { + resolve(results); + } + }); + }); + promises.push(promise); + }); + + Promise.all(promises) + .then(() => { + if (!errorOccurred) { + res.status(200).json({success: true, quizId: data[0].quizId, message: 'All responses saved successfully.' }); + } + }) + .catch((error) => { + console.error('Error saving responses:', error); + res.status(500).send('Internal Server Error'); + }) + .finally(() => { + connection.end(); + }); +}; + +module.exports = saveQuizResponse; diff --git a/src/routes/api/topPerformers.js b/src/routes/api/topPerformers.js new file mode 100644 index 0000000..6f620ad --- /dev/null +++ b/src/routes/api/topPerformers.js @@ -0,0 +1,231 @@ +const mysql = require("mysql2"); +const topPerformers = (req, res) => { + // res.send(req.query.doa); //get + // res.send(req.body.doa); //post + + + const connection = mysql.createConnection({ + host: process.env.MARIA_HOST, + user: process.env.MARIA_USER, + password: process.env.MARIA_PASS, + database: process.env.MARIA_DBNM + }); + + connection.connect((err) => { + if(err) { + console.error('Error connecting to the database:', err); + return; + } + console.log('Connected to the MariaDB database.'); + }); + + const data = req.body; + + const query = `SELECT * FROM top_performers`; + connection.query(query, (err, results) => { + if (err) { + console.error('Error inserting data:', err); + res.status(500).send('Internal Server Error'); + return; + } + res.status(200).json(results); + }); + + }; + + module.exports = topPerformers; + + + +// let performersData = [ +// { +// id: "1", +// name: "Eiden", +// score: "48/50", +// points: "999", +// rank: "1", +// program: "Graduate Program", +// avatar: "/assets/avatar1.png" +// }, +// { +// id: "2", +// name: "Jackson", +// score: "45/50", +// points: "997", +// rank: "2", +// program: "Graduate Program", +// avatar: "/assets/avatar2.png" +// }, +// { +// id: "3", +// name: "Emma Aria", +// score: "43/50", +// points: "994", +// rank: "3", +// program: "Graduate Program", +// avatar: "/assets/avatar3.png" +// }, +// { +// id: "4", +// name: "John Doe", +// score: "40/50", +// points: "990", +// rank: "4", +// program: "Graduate Program", +// avatar: "/assets/avatar4.png" +// }, +// { +// id: "5", +// name: "Jane Cooper", +// score: "37/50", +// points: "987", +// rank: "5", +// program: "Graduate Program", +// avatar: "/assets/avatar5.png" +// }, +// { +// id: "6", +// name: "John Doe", +// score: "35/50", +// points: "982", +// rank: "6", +// program: "Graduate Program", +// avatar: "/assets/avatar6.png" +// }, +// { +// id: "7", +// name: "Alice", +// score: "33/50", +// points: "980", +// rank: "7", +// program: "Graduate Program", +// avatar: "/assets/avatar1.png" +// }, +// { +// id: "8", +// name: "Bob", +// score: "32/50", +// points: "978", +// rank: "8", +// program: "Graduate Program", +// avatar: "/assets/avatar2.png" +// }, +// { +// id: "9", +// name: "Charlie", +// score: "30/50", +// points: "975", +// rank: "9", +// program: "Graduate Program", +// avatar: "/assets/avatar3.png" +// }, +// { +// id: "10", +// name: "Diana", +// score: "28/50", +// points: "972", +// rank: "10", +// program: "Graduate Program", +// avatar: "/assets/avatar4.png" +// }, +// { +// id: "11", +// name: "Edward", +// score: "27/50", +// points: "970", +// rank: "11", +// program: "Graduate Program", +// avatar: "/assets/avatar5.png" +// }, +// { +// id: "12", +// name: "Fiona", +// score: "26/50", +// points: "968", +// rank: "12", +// program: "Graduate Program", +// avatar: "/assets/avatar6.png" +// }, +// { +// id: "13", +// name: "George", +// score: "25/50", +// points: "965", +// rank: "13", +// program: "Graduate Program", +// avatar: "/assets/avatar1.png" +// }, +// { +// id: "14", +// name: "Hannah", +// score: "23/50", +// points: "962", +// rank: "14", +// program: "Graduate Program", +// avatar: "/assets/avatar2.png" +// }, +// { +// id: "15", +// name: "Ian", +// score: "22/50", +// points: "960", +// rank: "15", +// program: "Graduate Program", +// avatar: "/assets/avatar3.png" +// }, +// { +// id: "16", +// name: "Julia", +// score: "20/50", +// points: "957", +// rank: "16", +// program: "Graduate Program", +// avatar: "/assets/avatar4.png" +// }, +// { +// id: "17", +// name: "Kyle", +// score: "19/50", +// points: "955", +// rank: "17", +// program: "Graduate Program", +// avatar: "/assets/avatar5.png" +// }, +// { +// id: "18", +// name: "Laura", +// score: "18/50", +// points: "953", +// rank: "18", +// program: "Graduate Program", +// avatar: "/assets/avatar6.png" +// }, +// { +// id: "19", +// name: "Michael", +// score: "17/50", +// points: "950", +// rank: "19", +// program: "Graduate Program", +// avatar: "/assets/avatar1.png" +// }, +// { +// id: "20", +// name: "Nancy", +// score: "16/50", +// points: "947", +// rank: "20", +// program: "Graduate Program", +// avatar: "/assets/avatar2.png" +// }, +// { +// id: "21", +// name: "Oliver", +// score: "15/50", +// points: "945", +// rank: "21", +// program: "Graduate Program", +// avatar: "/assets/avatar3.png" +// } +// ]; +// res.json(performersData); \ No newline at end of file diff --git a/src/routes/v1/api.route.js b/src/routes/v1/api.route.js new file mode 100644 index 0000000..ad6249d --- /dev/null +++ b/src/routes/v1/api.route.js @@ -0,0 +1,378 @@ +// const express = require('express'); +const express = require("express"); +const Ping = require("../api/apiTest"); +const apiTest = require("../api/apiTest"); +const topPerformers = require("../api/topPerformers"); +const classMates = require("../api/classMates"); +const continueLearning = require("../api/continueLearning"); +const knowledgeQuests = require("../api/knowledgeQuests"); +const quizzesScore = require("../api/quizzesScore"); +const quizModuleData = require("../api/quizModuleData"); +const knowledgeQuestsAllContent = require("../api/knowledgeQuestsAllContent"); +const knowledgeQuestsCompleted = require("../api/knowledgeQuestsCompleted"); +const quizModuleList = require("../api/quizModuleList"); +const quizNewModule = require("../api/quizNewModule"); +const newQuiz = require("../api/newQuiz"); +const quizList = require("../api/quizList"); +const savePostData = require("../api/savePostData"); +// const signIn = require("../api/signIn"); +const questionList = require("../api/questionList"); +const newQuestion = require("../api/newQuestion"); +const saveQuizResponse = require("../api/saveQuizResponse"); +const getGameScore = require("../api/getGameScore"); +const resultAfterQuizSubmit = require("../api/resultAfterQuizSubmit"); +const generateQuestions = require("../api/generateQuestions"); +const saveGameScore = require("../api/saveGameScore"); + + +const router = express.Router(); +/* GET home page. */ +router.get("/ping", (req, res) => { + Ping(req, res); + }); + + /* GET home page. */ + router.get("/apiTest", (req, res) => { + apiTest(req, res); + }); + + // Classmates Directory page top performers section data + router.get("/top-performers", (req, res) => { + topPerformers(req, res); + }); + + // Classmates Directory page top class mates section data + router.get("/class-mates", (req, res) => { + classMates(req, res); + }); + + // Student Dashboard page Continue Learning section data + router.get("/continue-learning", (req, res) => { + continueLearning(req, res); + }); + + // Student Dashboard page knowledge-Quests section data + router.get("/knowledge-quests", (req, res) => { + knowledgeQuests(req, res); + }); + + // Progress Review page quiz details section data + router.get("/quiz-score", (req, res) => { + quizzesScore(req, res); + }); + + // Progress Review page quiz module section data + router.get("/quiz-module", (req, res) => { + quizModuleData(req, res); + }); + + // knowledge Quests page All Content section data + router.get("/all-assesment", (req, res) => { + knowledgeQuestsAllContent(req, res); + }); + + // knowledge Quests page Completed section data + router.get("/complete-assesment", (req, res) => { + knowledgeQuestsCompleted(req, res); + }); + + /* GET home page. */ + router.post("/savePostData", (req, res) => { + savePostData(req, res); + }); + + // For Sign in + // router.post("/signin", (req, res) => { + // signIn(req, res); + // }); + + // For Quiz Module list Data + router.get("/quiz-module-list", (req, res) => { + quizModuleList(req, res); + }); + + // For Create new module + router.post("/create-module", (req, res) => { + quizNewModule(req, res); + }); + + // For Create new Quiz + router.post("/create-quiz", (req, res) => { + newQuiz(req, res); + }); + + // For Quiz List data + router.get("/quiz-list", (req, res) => { + quizList(req, res); + }); + + // For Quiz Question List data + router.get("/question-list", (req, res) => { + questionList(req, res); + }); + + // For Quiz Question List data + router.post("/create-question", (req, res) => { + newQuestion(req, res); + }); + + // For Quiz Question List data + router.post("/save-quiz-response", (req, res) => { + saveQuizResponse(req, res); + }); + + // For Quiz Question List data + router.post("/getGameScore", (req, res) => { + getGameScore(req, res); + }); + + + // For Quiz Result After Submit Quiz + router.get("/quizresult-aftersubmit", (req, res) => { + resultAfterQuizSubmit(req, res); + }); + + // For Quiz Result After Submit Quiz + router.post("/generateQuestions", (req, res) => { + generateQuestions(req, res); + }); + + // For Quiz Result After Submit Quiz + router.post("/saveGameScore", (req, res) => { + saveGameScore(req, res); + }); + + +module.exports = router; + +/** + * @swagger + * tags: + * name: Users + * description: User management and retrieval + */ + +/** + * @swagger + * /users: + * post: + * summary: Create a user + * description: Only admins can create other users. + * tags: [Users] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - email + * - password + * - role + * properties: + * name: + * type: string + * email: + * type: string + * format: email + * description: must be unique + * password: + * type: string + * format: password + * minLength: 8 + * description: At least one number and one letter + * role: + * type: string + * enum: [user, admin] + * example: + * name: fake name + * email: fake@example.com + * password: password1 + * role: user + * responses: + * "201": + * description: Created + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * "400": + * $ref: '#/components/responses/DuplicateEmail' + * "401": + * $ref: '#/components/responses/Unauthorized' + * "403": + * $ref: '#/components/responses/Forbidden' + * + * get: + * summary: Get all users + * description: Only admins can retrieve all users. + * tags: [Users] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: name + * schema: + * type: string + * description: User name + * - in: query + * name: role + * schema: + * type: string + * description: User role + * - in: query + * name: sortBy + * schema: + * type: string + * description: sort by query in the form of field:desc/asc (ex. name:asc) + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * default: 10 + * description: Maximum number of users + * - in: query + * name: page + * schema: + * type: integer + * minimum: 1 + * default: 1 + * description: Page number + * responses: + * "200": + * description: OK + * content: + * application/json: + * schema: + * type: object + * properties: + * results: + * type: array + * items: + * $ref: '#/components/schemas/User' + * page: + * type: integer + * example: 1 + * limit: + * type: integer + * example: 10 + * totalPages: + * type: integer + * example: 1 + * totalResults: + * type: integer + * example: 1 + * "401": + * $ref: '#/components/responses/Unauthorized' + * "403": + * $ref: '#/components/responses/Forbidden' + */ + +/** + * @swagger + * /users/{id}: + * get: + * summary: Get a user + * description: Logged in users can fetch only their own user information. Only admins can fetch other users. + * tags: [Users] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: User id + * responses: + * "200": + * description: OK + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * "401": + * $ref: '#/components/responses/Unauthorized' + * "403": + * $ref: '#/components/responses/Forbidden' + * "404": + * $ref: '#/components/responses/NotFound' + * + * patch: + * summary: Update a user + * description: Logged in users can only update their own information. Only admins can update other users. + * tags: [Users] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: User id + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * email: + * type: string + * format: email + * description: must be unique + * password: + * type: string + * format: password + * minLength: 8 + * description: At least one number and one letter + * example: + * name: fake name + * email: fake@example.com + * password: password1 + * responses: + * "200": + * description: OK + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * "400": + * $ref: '#/components/responses/DuplicateEmail' + * "401": + * $ref: '#/components/responses/Unauthorized' + * "403": + * $ref: '#/components/responses/Forbidden' + * "404": + * $ref: '#/components/responses/NotFound' + * + * delete: + * summary: Delete a user + * description: Logged in users can delete only themselves. Only admins can delete other users. + * tags: [Users] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: User id + * responses: + * "200": + * description: No content + * "401": + * $ref: '#/components/responses/Unauthorized' + * "403": + * $ref: '#/components/responses/Forbidden' + * "404": + * $ref: '#/components/responses/NotFound' + */ diff --git a/src/routes/v1/auth.route.js b/src/routes/v1/auth.route.js new file mode 100644 index 0000000..220fde3 --- /dev/null +++ b/src/routes/v1/auth.route.js @@ -0,0 +1,291 @@ +const express = require('express'); +const validate = require('../../middlewares/validate'); +const authValidation = require('../../validations/auth.validation'); +const authController = require('../../controllers/auth.controller'); +const auth = require('../../middlewares/auth'); + +const router = express.Router(); + +router.post('/register', validate(authValidation.register), authController.register); +router.post('/login', validate(authValidation.login), authController.login); +router.post('/logout', validate(authValidation.logout), authController.logout); +router.post('/refresh-tokens', validate(authValidation.refreshTokens), authController.refreshTokens); +router.post('/forgot-password', validate(authValidation.forgotPassword), authController.forgotPassword); +router.post('/reset-password', validate(authValidation.resetPassword), authController.resetPassword); +router.post('/send-verification-email', auth(), authController.sendVerificationEmail); +router.post('/verify-email', validate(authValidation.verifyEmail), authController.verifyEmail); + +module.exports = router; + +/** + * @swagger + * tags: + * name: Auth + * description: Authentication + */ + +/** + * @swagger + * /auth/register: + * post: + * summary: Register as user + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - email + * - password + * properties: + * name: + * type: string + * email: + * type: string + * format: email + * description: must be unique + * password: + * type: string + * format: password + * minLength: 8 + * description: At least one number and one letter + * example: + * name: fake name + * email: fake@example.com + * password: password1 + * responses: + * "201": + * description: Created + * content: + * application/json: + * schema: + * type: object + * properties: + * user: + * $ref: '#/components/schemas/User' + * tokens: + * $ref: '#/components/schemas/AuthTokens' + * "400": + * $ref: '#/components/responses/DuplicateEmail' + */ + +/** + * @swagger + * /auth/login: + * post: + * summary: Login + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - email + * - password + * properties: + * email: + * type: string + * format: email + * password: + * type: string + * format: password + * example: + * email: fake@example.com + * password: password1 + * responses: + * "200": + * description: OK + * content: + * application/json: + * schema: + * type: object + * properties: + * user: + * $ref: '#/components/schemas/User' + * tokens: + * $ref: '#/components/schemas/AuthTokens' + * "401": + * description: Invalid email or password + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * code: 401 + * message: Invalid email or password + */ + +/** + * @swagger + * /auth/logout: + * post: + * summary: Logout + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - refreshToken + * properties: + * refreshToken: + * type: string + * example: + * refreshToken: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1ZWJhYzUzNDk1NGI1NDEzOTgwNmMxMTIiLCJpYXQiOjE1ODkyOTg0ODQsImV4cCI6MTU4OTMwMDI4NH0.m1U63blB0MLej_WfB7yC2FTMnCziif9X8yzwDEfJXAg + * responses: + * "204": + * description: No content + * "404": + * $ref: '#/components/responses/NotFound' + */ + +/** + * @swagger + * /auth/refresh-tokens: + * post: + * summary: Refresh auth tokens + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - refreshToken + * properties: + * refreshToken: + * type: string + * example: + * refreshToken: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1ZWJhYzUzNDk1NGI1NDEzOTgwNmMxMTIiLCJpYXQiOjE1ODkyOTg0ODQsImV4cCI6MTU4OTMwMDI4NH0.m1U63blB0MLej_WfB7yC2FTMnCziif9X8yzwDEfJXAg + * responses: + * "200": + * description: OK + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthTokens' + * "401": + * $ref: '#/components/responses/Unauthorized' + */ + +/** + * @swagger + * /auth/forgot-password: + * post: + * summary: Forgot password + * description: An email will be sent to reset password. + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - email + * properties: + * email: + * type: string + * format: email + * example: + * email: fake@example.com + * responses: + * "204": + * description: No content + * "404": + * $ref: '#/components/responses/NotFound' + */ + +/** + * @swagger + * /auth/reset-password: + * post: + * summary: Reset password + * tags: [Auth] + * parameters: + * - in: query + * name: token + * required: true + * schema: + * type: string + * description: The reset password token + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - password + * properties: + * password: + * type: string + * format: password + * minLength: 8 + * description: At least one number and one letter + * example: + * password: password1 + * responses: + * "204": + * description: No content + * "401": + * description: Password reset failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * code: 401 + * message: Password reset failed + */ + +/** + * @swagger + * /auth/send-verification-email: + * post: + * summary: Send verification email + * description: An email will be sent to verify email. + * tags: [Auth] + * security: + * - bearerAuth: [] + * responses: + * "204": + * description: No content + * "401": + * $ref: '#/components/responses/Unauthorized' + */ + +/** + * @swagger + * /auth/verify-email: + * post: + * summary: verify email + * tags: [Auth] + * parameters: + * - in: query + * name: token + * required: true + * schema: + * type: string + * description: The verify email token + * responses: + * "204": + * description: No content + * "401": + * description: verify email failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * code: 401 + * message: verify email failed + */ diff --git a/src/routes/v1/docs.route.js b/src/routes/v1/docs.route.js new file mode 100644 index 0000000..f345fcc --- /dev/null +++ b/src/routes/v1/docs.route.js @@ -0,0 +1,21 @@ +const express = require('express'); +const swaggerJsdoc = require('swagger-jsdoc'); +const swaggerUi = require('swagger-ui-express'); +const swaggerDefinition = require('../../docs/swaggerDef'); + +const router = express.Router(); + +const specs = swaggerJsdoc({ + swaggerDefinition, + apis: ['src/docs/*.yml', 'src/routes/v1/*.js'], +}); + +router.use('/', swaggerUi.serve); +router.get( + '/', + swaggerUi.setup(specs, { + explorer: true, + }) +); + +module.exports = router; diff --git a/src/routes/v1/index.js b/src/routes/v1/index.js new file mode 100644 index 0000000..4bf29fa --- /dev/null +++ b/src/routes/v1/index.js @@ -0,0 +1,44 @@ +const express = require('express'); +const authRoute = require('./auth.route'); +const userRoute = require('./user.route'); +const apiRoute = require('./api.route'); +const docsRoute = require('./docs.route'); +const config = require('../../config/config'); + +const router = express.Router(); + +const defaultRoutes = [ + { + path: '/auth', + route: authRoute, + }, + { + path: '/users', + route: userRoute, + }, + { + path: '/api', + route: apiRoute, + }, +]; + +const devRoutes = [ + // routes available only in development mode + { + path: '/docs', + route: docsRoute, + }, +]; + +defaultRoutes.forEach((route) => { + router.use(route.path, route.route); +}); + +/* istanbul ignore next */ +if (config.env === 'development') { + devRoutes.forEach((route) => { + router.use(route.path, route.route); + }); +} + +module.exports = router; diff --git a/src/routes/v1/user.route.js b/src/routes/v1/user.route.js new file mode 100644 index 0000000..a0a329c --- /dev/null +++ b/src/routes/v1/user.route.js @@ -0,0 +1,252 @@ +const express = require('express'); +const auth = require('../../middlewares/auth'); +const validate = require('../../middlewares/validate'); +const userValidation = require('../../validations/user.validation'); +const userController = require('../../controllers/user.controller'); + +const router = express.Router(); + +router + .route('/') + .post(auth('manageUsers'), validate(userValidation.createUser), userController.createUser) + .get(auth('getUsers'), validate(userValidation.getUsers), userController.getUsers); + +router + .route('/:userId') + .get(auth('getUsers'), validate(userValidation.getUser), userController.getUser) + .patch(auth('manageUsers'), validate(userValidation.updateUser), userController.updateUser) + .delete(auth('manageUsers'), validate(userValidation.deleteUser), userController.deleteUser); + +module.exports = router; + +/** + * @swagger + * tags: + * name: Users + * description: User management and retrieval + */ + +/** + * @swagger + * /users: + * post: + * summary: Create a user + * description: Only admins can create other users. + * tags: [Users] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - email + * - password + * - role + * properties: + * name: + * type: string + * email: + * type: string + * format: email + * description: must be unique + * password: + * type: string + * format: password + * minLength: 8 + * description: At least one number and one letter + * role: + * type: string + * enum: [user, admin] + * example: + * name: fake name + * email: fake@example.com + * password: password1 + * role: user + * responses: + * "201": + * description: Created + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * "400": + * $ref: '#/components/responses/DuplicateEmail' + * "401": + * $ref: '#/components/responses/Unauthorized' + * "403": + * $ref: '#/components/responses/Forbidden' + * + * get: + * summary: Get all users + * description: Only admins can retrieve all users. + * tags: [Users] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: name + * schema: + * type: string + * description: User name + * - in: query + * name: role + * schema: + * type: string + * description: User role + * - in: query + * name: sortBy + * schema: + * type: string + * description: sort by query in the form of field:desc/asc (ex. name:asc) + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * default: 10 + * description: Maximum number of users + * - in: query + * name: page + * schema: + * type: integer + * minimum: 1 + * default: 1 + * description: Page number + * responses: + * "200": + * description: OK + * content: + * application/json: + * schema: + * type: object + * properties: + * results: + * type: array + * items: + * $ref: '#/components/schemas/User' + * page: + * type: integer + * example: 1 + * limit: + * type: integer + * example: 10 + * totalPages: + * type: integer + * example: 1 + * totalResults: + * type: integer + * example: 1 + * "401": + * $ref: '#/components/responses/Unauthorized' + * "403": + * $ref: '#/components/responses/Forbidden' + */ + +/** + * @swagger + * /users/{id}: + * get: + * summary: Get a user + * description: Logged in users can fetch only their own user information. Only admins can fetch other users. + * tags: [Users] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: User id + * responses: + * "200": + * description: OK + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * "401": + * $ref: '#/components/responses/Unauthorized' + * "403": + * $ref: '#/components/responses/Forbidden' + * "404": + * $ref: '#/components/responses/NotFound' + * + * patch: + * summary: Update a user + * description: Logged in users can only update their own information. Only admins can update other users. + * tags: [Users] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: User id + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * email: + * type: string + * format: email + * description: must be unique + * password: + * type: string + * format: password + * minLength: 8 + * description: At least one number and one letter + * example: + * name: fake name + * email: fake@example.com + * password: password1 + * responses: + * "200": + * description: OK + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * "400": + * $ref: '#/components/responses/DuplicateEmail' + * "401": + * $ref: '#/components/responses/Unauthorized' + * "403": + * $ref: '#/components/responses/Forbidden' + * "404": + * $ref: '#/components/responses/NotFound' + * + * delete: + * summary: Delete a user + * description: Logged in users can delete only themselves. Only admins can delete other users. + * tags: [Users] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: User id + * responses: + * "200": + * description: No content + * "401": + * $ref: '#/components/responses/Unauthorized' + * "403": + * $ref: '#/components/responses/Forbidden' + * "404": + * $ref: '#/components/responses/NotFound' + */ diff --git a/src/services/auth.service.js b/src/services/auth.service.js new file mode 100644 index 0000000..18d9b22 --- /dev/null +++ b/src/services/auth.service.js @@ -0,0 +1,99 @@ +const httpStatus = require('http-status'); +const tokenService = require('./token.service'); +const userService = require('./user.service'); +const Token = require('../models/token.model'); +const ApiError = require('../utils/ApiError'); +const { tokenTypes } = require('../config/tokens'); + +/** + * Login with username and password + * @param {string} email + * @param {string} password + * @returns {Promise} + */ +const loginUserWithEmailAndPassword = async (email, password) => { + const user = await userService.getUserByEmail(email); + if (!user || !(await user.isPasswordMatch(password))) { + throw new ApiError(httpStatus.UNAUTHORIZED, 'Incorrect email or password'); + } + return user; +}; + +/** + * Logout + * @param {string} refreshToken + * @returns {Promise} + */ +const logout = async (refreshToken) => { + const refreshTokenDoc = await Token.findOne({ token: refreshToken, type: tokenTypes.REFRESH, blacklisted: false }); + if (!refreshTokenDoc) { + throw new ApiError(httpStatus.NOT_FOUND, 'Not found'); + } + await refreshTokenDoc.remove(); +}; + +/** + * Refresh auth tokens + * @param {string} refreshToken + * @returns {Promise} + */ +const refreshAuth = async (refreshToken) => { + try { + const refreshTokenDoc = await tokenService.verifyToken(refreshToken, tokenTypes.REFRESH); + const user = await userService.getUserById(refreshTokenDoc.user); + if (!user) { + throw new Error(); + } + await refreshTokenDoc.remove(); + return tokenService.generateAuthTokens(user); + } catch (error) { + throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); + } +}; + +/** + * Reset password + * @param {string} resetPasswordToken + * @param {string} newPassword + * @returns {Promise} + */ +const resetPassword = async (resetPasswordToken, newPassword) => { + try { + const resetPasswordTokenDoc = await tokenService.verifyToken(resetPasswordToken, tokenTypes.RESET_PASSWORD); + const user = await userService.getUserById(resetPasswordTokenDoc.user); + if (!user) { + throw new Error(); + } + await userService.updateUserById(user.id, { password: newPassword }); + await Token.deleteMany({ user: user.id, type: tokenTypes.RESET_PASSWORD }); + } catch (error) { + throw new ApiError(httpStatus.UNAUTHORIZED, 'Password reset failed'); + } +}; + +/** + * Verify email + * @param {string} verifyEmailToken + * @returns {Promise} + */ +const verifyEmail = async (verifyEmailToken) => { + try { + const verifyEmailTokenDoc = await tokenService.verifyToken(verifyEmailToken, tokenTypes.VERIFY_EMAIL); + const user = await userService.getUserById(verifyEmailTokenDoc.user); + if (!user) { + throw new Error(); + } + await Token.deleteMany({ user: user.id, type: tokenTypes.VERIFY_EMAIL }); + await userService.updateUserById(user.id, { isEmailVerified: true }); + } catch (error) { + throw new ApiError(httpStatus.UNAUTHORIZED, 'Email verification failed'); + } +}; + +module.exports = { + loginUserWithEmailAndPassword, + logout, + refreshAuth, + resetPassword, + verifyEmail, +}; diff --git a/src/services/email.service.js b/src/services/email.service.js new file mode 100644 index 0000000..9323512 --- /dev/null +++ b/src/services/email.service.js @@ -0,0 +1,63 @@ +const nodemailer = require('nodemailer'); +const config = require('../config/config'); +const logger = require('../config/logger'); + +const transport = nodemailer.createTransport(config.email.smtp); +/* istanbul ignore next */ +if (config.env !== 'test') { + transport + .verify() + .then(() => logger.info('Connected to email server')) + .catch(() => logger.warn('Unable to connect to email server. Make sure you have configured the SMTP options in .env')); +} + +/** + * Send an email + * @param {string} to + * @param {string} subject + * @param {string} text + * @returns {Promise} + */ +const sendEmail = async (to, subject, text) => { + const msg = { from: config.email.from, to, subject, text }; + await transport.sendMail(msg); +}; + +/** + * Send reset password email + * @param {string} to + * @param {string} token + * @returns {Promise} + */ +const sendResetPasswordEmail = async (to, token) => { + const subject = 'Reset password'; + // replace this url with the link to the reset password page of your front-end app + const resetPasswordUrl = `http://link-to-app/reset-password?token=${token}`; + const text = `Dear user, +To reset your password, click on this link: ${resetPasswordUrl} +If you did not request any password resets, then ignore this email.`; + await sendEmail(to, subject, text); +}; + +/** + * Send verification email + * @param {string} to + * @param {string} token + * @returns {Promise} + */ +const sendVerificationEmail = async (to, token) => { + const subject = 'Email Verification'; + // replace this url with the link to the email verification page of your front-end app + const verificationEmailUrl = `http://link-to-app/verify-email?token=${token}`; + const text = `Dear user, +To verify your email, click on this link: ${verificationEmailUrl} +If you did not create an account, then ignore this email.`; + await sendEmail(to, subject, text); +}; + +module.exports = { + transport, + sendEmail, + sendResetPasswordEmail, + sendVerificationEmail, +}; diff --git a/src/services/index.js b/src/services/index.js new file mode 100644 index 0000000..73547db --- /dev/null +++ b/src/services/index.js @@ -0,0 +1,4 @@ +module.exports.authService = require('./auth.service'); +module.exports.emailService = require('./email.service'); +module.exports.tokenService = require('./token.service'); +module.exports.userService = require('./user.service'); diff --git a/src/services/token.service.js b/src/services/token.service.js new file mode 100644 index 0000000..e5e8fba --- /dev/null +++ b/src/services/token.service.js @@ -0,0 +1,123 @@ +const jwt = require('jsonwebtoken'); +const moment = require('moment'); +const httpStatus = require('http-status'); +const config = require('../config/config'); +const userService = require('./user.service'); +const { Token } = require('../models'); +const ApiError = require('../utils/ApiError'); +const { tokenTypes } = require('../config/tokens'); + +/** + * Generate token + * @param {ObjectId} userId + * @param {Moment} expires + * @param {string} type + * @param {string} [secret] + * @returns {string} + */ +const generateToken = (userId, expires, type, secret = config.jwt.secret) => { + const payload = { + sub: userId, + iat: moment().unix(), + exp: expires.unix(), + type, + }; + return jwt.sign(payload, secret); +}; + +/** + * Save a token + * @param {string} token + * @param {ObjectId} userId + * @param {Moment} expires + * @param {string} type + * @param {boolean} [blacklisted] + * @returns {Promise} + */ +const saveToken = async (token, userId, expires, type, blacklisted = false) => { + const tokenDoc = await Token.create({ + token, + user: userId, + expires: expires.toDate(), + type, + blacklisted, + }); + return tokenDoc; +}; + +/** + * Verify token and return token doc (or throw an error if it is not valid) + * @param {string} token + * @param {string} type + * @returns {Promise} + */ +const verifyToken = async (token, type) => { + const payload = jwt.verify(token, config.jwt.secret); + const tokenDoc = await Token.findOne({ token, type, user: payload.sub, blacklisted: false }); + if (!tokenDoc) { + throw new Error('Token not found'); + } + return tokenDoc; +}; + +/** + * Generate auth tokens + * @param {User} user + * @returns {Promise} + */ +const generateAuthTokens = async (user) => { + const accessTokenExpires = moment().add(config.jwt.accessExpirationMinutes, 'minutes'); + const accessToken = generateToken(user.id, accessTokenExpires, tokenTypes.ACCESS); + + const refreshTokenExpires = moment().add(config.jwt.refreshExpirationDays, 'days'); + const refreshToken = generateToken(user.id, refreshTokenExpires, tokenTypes.REFRESH); + await saveToken(refreshToken, user.id, refreshTokenExpires, tokenTypes.REFRESH); + + return { + access: { + token: accessToken, + expires: accessTokenExpires.toDate(), + }, + refresh: { + token: refreshToken, + expires: refreshTokenExpires.toDate(), + }, + }; +}; + +/** + * Generate reset password token + * @param {string} email + * @returns {Promise} + */ +const generateResetPasswordToken = async (email) => { + const user = await userService.getUserByEmail(email); + if (!user) { + throw new ApiError(httpStatus.NOT_FOUND, 'No users found with this email'); + } + const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); + const resetPasswordToken = generateToken(user.id, expires, tokenTypes.RESET_PASSWORD); + await saveToken(resetPasswordToken, user.id, expires, tokenTypes.RESET_PASSWORD); + return resetPasswordToken; +}; + +/** + * Generate verify email token + * @param {User} user + * @returns {Promise} + */ +const generateVerifyEmailToken = async (user) => { + const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes'); + const verifyEmailToken = generateToken(user.id, expires, tokenTypes.VERIFY_EMAIL); + await saveToken(verifyEmailToken, user.id, expires, tokenTypes.VERIFY_EMAIL); + return verifyEmailToken; +}; + +module.exports = { + generateToken, + saveToken, + verifyToken, + generateAuthTokens, + generateResetPasswordToken, + generateVerifyEmailToken, +}; diff --git a/src/services/user.service.js b/src/services/user.service.js new file mode 100644 index 0000000..f75de0a --- /dev/null +++ b/src/services/user.service.js @@ -0,0 +1,89 @@ +const httpStatus = require('http-status'); +const { User } = require('../models'); +const ApiError = require('../utils/ApiError'); + +/** + * Create a user + * @param {Object} userBody + * @returns {Promise} + */ +const createUser = async (userBody) => { + if (await User.isEmailTaken(userBody.email)) { + throw new ApiError(httpStatus.BAD_REQUEST, 'Email already taken'); + } + return User.create(userBody); +}; + +/** + * Query for users + * @param {Object} filter - Mongo filter + * @param {Object} options - Query options + * @param {string} [options.sortBy] - Sort option in the format: sortField:(desc|asc) + * @param {number} [options.limit] - Maximum number of results per page (default = 10) + * @param {number} [options.page] - Current page (default = 1) + * @returns {Promise} + */ +const queryUsers = async (filter, options) => { + const users = await User.paginate(filter, options); + return users; +}; + +/** + * Get user by id + * @param {ObjectId} id + * @returns {Promise} + */ +const getUserById = async (id) => { + return User.findById(id); +}; + +/** + * Get user by email + * @param {string} email + * @returns {Promise} + */ +const getUserByEmail = async (email) => { + return User.findOne({ email }); +}; + +/** + * Update user by id + * @param {ObjectId} userId + * @param {Object} updateBody + * @returns {Promise} + */ +const updateUserById = async (userId, updateBody) => { + const user = await getUserById(userId); + if (!user) { + throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); + } + if (updateBody.email && (await User.isEmailTaken(updateBody.email, userId))) { + throw new ApiError(httpStatus.BAD_REQUEST, 'Email already taken'); + } + Object.assign(user, updateBody); + await user.save(); + return user; +}; + +/** + * Delete user by id + * @param {ObjectId} userId + * @returns {Promise} + */ +const deleteUserById = async (userId) => { + const user = await getUserById(userId); + if (!user) { + throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); + } + await user.remove(); + return user; +}; + +module.exports = { + createUser, + queryUsers, + getUserById, + getUserByEmail, + updateUserById, + deleteUserById, +}; diff --git a/src/utils/ApiError.js b/src/utils/ApiError.js new file mode 100644 index 0000000..2494c1f --- /dev/null +++ b/src/utils/ApiError.js @@ -0,0 +1,14 @@ +class ApiError extends Error { + constructor(statusCode, message, isOperational = true, stack = '') { + super(message); + this.statusCode = statusCode; + this.isOperational = isOperational; + if (stack) { + this.stack = stack; + } else { + Error.captureStackTrace(this, this.constructor); + } + } +} + +module.exports = ApiError; diff --git a/src/utils/catchAsync.js b/src/utils/catchAsync.js new file mode 100644 index 0000000..387efc6 --- /dev/null +++ b/src/utils/catchAsync.js @@ -0,0 +1,5 @@ +const catchAsync = (fn) => (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch((err) => next(err)); +}; + +module.exports = catchAsync; diff --git a/src/utils/pick.js b/src/utils/pick.js new file mode 100644 index 0000000..e4b13c2 --- /dev/null +++ b/src/utils/pick.js @@ -0,0 +1,17 @@ +/** + * Create an object composed of the picked object properties + * @param {Object} object + * @param {string[]} keys + * @returns {Object} + */ +const pick = (object, keys) => { + return keys.reduce((obj, key) => { + if (object && Object.prototype.hasOwnProperty.call(object, key)) { + // eslint-disable-next-line no-param-reassign + obj[key] = object[key]; + } + return obj; + }, {}); +}; + +module.exports = pick; diff --git a/src/validations/auth.validation.js b/src/validations/auth.validation.js new file mode 100644 index 0000000..e562c98 --- /dev/null +++ b/src/validations/auth.validation.js @@ -0,0 +1,60 @@ +const Joi = require('joi'); +const { password } = require('./custom.validation'); + +const register = { + body: Joi.object().keys({ + email: Joi.string().required().email(), + password: Joi.string().required().custom(password), + name: Joi.string().required(), + }), +}; + +const login = { + body: Joi.object().keys({ + email: Joi.string().required(), + password: Joi.string().required(), + }), +}; + +const logout = { + body: Joi.object().keys({ + refreshToken: Joi.string().required(), + }), +}; + +const refreshTokens = { + body: Joi.object().keys({ + refreshToken: Joi.string().required(), + }), +}; + +const forgotPassword = { + body: Joi.object().keys({ + email: Joi.string().email().required(), + }), +}; + +const resetPassword = { + query: Joi.object().keys({ + token: Joi.string().required(), + }), + body: Joi.object().keys({ + password: Joi.string().required().custom(password), + }), +}; + +const verifyEmail = { + query: Joi.object().keys({ + token: Joi.string().required(), + }), +}; + +module.exports = { + register, + login, + logout, + refreshTokens, + forgotPassword, + resetPassword, + verifyEmail, +}; diff --git a/src/validations/custom.validation.js b/src/validations/custom.validation.js new file mode 100644 index 0000000..aa0c5f6 --- /dev/null +++ b/src/validations/custom.validation.js @@ -0,0 +1,21 @@ +const objectId = (value, helpers) => { + if (!value.match(/^[0-9a-fA-F]{24}$/)) { + return helpers.message('"{{#label}}" must be a valid mongo id'); + } + return value; +}; + +const password = (value, helpers) => { + if (value.length < 8) { + return helpers.message('password must be at least 8 characters'); + } + if (!value.match(/\d/) || !value.match(/[a-zA-Z]/)) { + return helpers.message('password must contain at least 1 letter and 1 number'); + } + return value; +}; + +module.exports = { + objectId, + password, +}; diff --git a/src/validations/index.js b/src/validations/index.js new file mode 100644 index 0000000..16d6baa --- /dev/null +++ b/src/validations/index.js @@ -0,0 +1,2 @@ +module.exports.authValidation = require('./auth.validation'); +module.exports.userValidation = require('./user.validation'); diff --git a/src/validations/user.validation.js b/src/validations/user.validation.js new file mode 100644 index 0000000..1be6ce4 --- /dev/null +++ b/src/validations/user.validation.js @@ -0,0 +1,54 @@ +const Joi = require('joi'); +const { password, objectId } = require('./custom.validation'); + +const createUser = { + body: Joi.object().keys({ + email: Joi.string().required().email(), + password: Joi.string().required().custom(password), + name: Joi.string().required(), + role: Joi.string().required().valid('user', 'admin'), + }), +}; + +const getUsers = { + query: Joi.object().keys({ + name: Joi.string(), + role: Joi.string(), + sortBy: Joi.string(), + limit: Joi.number().integer(), + page: Joi.number().integer(), + }), +}; + +const getUser = { + params: Joi.object().keys({ + userId: Joi.string().custom(objectId), + }), +}; + +const updateUser = { + params: Joi.object().keys({ + userId: Joi.required().custom(objectId), + }), + body: Joi.object() + .keys({ + email: Joi.string().email(), + password: Joi.string().custom(password), + name: Joi.string(), + }) + .min(1), +}; + +const deleteUser = { + params: Joi.object().keys({ + userId: Joi.string().custom(objectId), + }), +}; + +module.exports = { + createUser, + getUsers, + getUser, + updateUser, + deleteUser, +}; diff --git a/tests/fixtures/token.fixture.js b/tests/fixtures/token.fixture.js new file mode 100644 index 0000000..4fe7a20 --- /dev/null +++ b/tests/fixtures/token.fixture.js @@ -0,0 +1,14 @@ +const moment = require('moment'); +const config = require('../../src/config/config'); +const { tokenTypes } = require('../../src/config/tokens'); +const tokenService = require('../../src/services/token.service'); +const { userOne, admin } = require('./user.fixture'); + +const accessTokenExpires = moment().add(config.jwt.accessExpirationMinutes, 'minutes'); +const userOneAccessToken = tokenService.generateToken(userOne._id, accessTokenExpires, tokenTypes.ACCESS); +const adminAccessToken = tokenService.generateToken(admin._id, accessTokenExpires, tokenTypes.ACCESS); + +module.exports = { + userOneAccessToken, + adminAccessToken, +}; diff --git a/tests/fixtures/user.fixture.js b/tests/fixtures/user.fixture.js new file mode 100644 index 0000000..ba98026 --- /dev/null +++ b/tests/fixtures/user.fixture.js @@ -0,0 +1,46 @@ +const mongoose = require('mongoose'); +const bcrypt = require('bcryptjs'); +const faker = require('faker'); +const User = require('../../src/models/user.model'); + +const password = 'password1'; +const salt = bcrypt.genSaltSync(8); +const hashedPassword = bcrypt.hashSync(password, salt); + +const userOne = { + _id: mongoose.Types.ObjectId(), + name: faker.name.findName(), + email: faker.internet.email().toLowerCase(), + password, + role: 'user', + isEmailVerified: false, +}; + +const userTwo = { + _id: mongoose.Types.ObjectId(), + name: faker.name.findName(), + email: faker.internet.email().toLowerCase(), + password, + role: 'user', + isEmailVerified: false, +}; + +const admin = { + _id: mongoose.Types.ObjectId(), + name: faker.name.findName(), + email: faker.internet.email().toLowerCase(), + password, + role: 'admin', + isEmailVerified: false, +}; + +const insertUsers = async (users) => { + await User.insertMany(users.map((user) => ({ ...user, password: hashedPassword }))); +}; + +module.exports = { + userOne, + userTwo, + admin, + insertUsers, +}; diff --git a/tests/integration/auth.test.js b/tests/integration/auth.test.js new file mode 100644 index 0000000..ef1ae44 --- /dev/null +++ b/tests/integration/auth.test.js @@ -0,0 +1,587 @@ +const request = require('supertest'); +const faker = require('faker'); +const httpStatus = require('http-status'); +const httpMocks = require('node-mocks-http'); +const moment = require('moment'); +const bcrypt = require('bcryptjs'); +const app = require('../../src/app'); +const config = require('../../src/config/config'); +const auth = require('../../src/middlewares/auth'); +const { tokenService, emailService } = require('../../src/services'); +const ApiError = require('../../src/utils/ApiError'); +const setupTestDB = require('../utils/setupTestDB'); +const { User, Token } = require('../../src/models'); +const { roleRights } = require('../../src/config/roles'); +const { tokenTypes } = require('../../src/config/tokens'); +const { userOne, admin, insertUsers } = require('../fixtures/user.fixture'); +const { userOneAccessToken, adminAccessToken } = require('../fixtures/token.fixture'); + +setupTestDB(); + +describe('Auth routes', () => { + describe('POST /v1/auth/register', () => { + let newUser; + beforeEach(() => { + newUser = { + name: faker.name.findName(), + email: faker.internet.email().toLowerCase(), + password: 'password1', + }; + }); + + test('should return 201 and successfully register user if request data is ok', async () => { + const res = await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.CREATED); + + expect(res.body.user).not.toHaveProperty('password'); + expect(res.body.user).toEqual({ + id: expect.anything(), + name: newUser.name, + email: newUser.email, + role: 'user', + isEmailVerified: false, + }); + + const dbUser = await User.findById(res.body.user.id); + expect(dbUser).toBeDefined(); + expect(dbUser.password).not.toBe(newUser.password); + expect(dbUser).toMatchObject({ name: newUser.name, email: newUser.email, role: 'user', isEmailVerified: false }); + + expect(res.body.tokens).toEqual({ + access: { token: expect.anything(), expires: expect.anything() }, + refresh: { token: expect.anything(), expires: expect.anything() }, + }); + }); + + test('should return 400 error if email is invalid', async () => { + newUser.email = 'invalidEmail'; + + await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.BAD_REQUEST); + }); + + test('should return 400 error if email is already used', async () => { + await insertUsers([userOne]); + newUser.email = userOne.email; + + await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.BAD_REQUEST); + }); + + test('should return 400 error if password length is less than 8 characters', async () => { + newUser.password = 'passwo1'; + + await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.BAD_REQUEST); + }); + + test('should return 400 error if password does not contain both letters and numbers', async () => { + newUser.password = 'password'; + + await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.BAD_REQUEST); + + newUser.password = '11111111'; + + await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.BAD_REQUEST); + }); + }); + + describe('POST /v1/auth/login', () => { + test('should return 200 and login user if email and password match', async () => { + await insertUsers([userOne]); + const loginCredentials = { + email: userOne.email, + password: userOne.password, + }; + + const res = await request(app).post('/v1/auth/login').send(loginCredentials).expect(httpStatus.OK); + + expect(res.body.user).toEqual({ + id: expect.anything(), + name: userOne.name, + email: userOne.email, + role: userOne.role, + isEmailVerified: userOne.isEmailVerified, + }); + + expect(res.body.tokens).toEqual({ + access: { token: expect.anything(), expires: expect.anything() }, + refresh: { token: expect.anything(), expires: expect.anything() }, + }); + }); + + test('should return 401 error if there are no users with that email', async () => { + const loginCredentials = { + email: userOne.email, + password: userOne.password, + }; + + const res = await request(app).post('/v1/auth/login').send(loginCredentials).expect(httpStatus.UNAUTHORIZED); + + expect(res.body).toEqual({ code: httpStatus.UNAUTHORIZED, message: 'Incorrect email or password' }); + }); + + test('should return 401 error if password is wrong', async () => { + await insertUsers([userOne]); + const loginCredentials = { + email: userOne.email, + password: 'wrongPassword1', + }; + + const res = await request(app).post('/v1/auth/login').send(loginCredentials).expect(httpStatus.UNAUTHORIZED); + + expect(res.body).toEqual({ code: httpStatus.UNAUTHORIZED, message: 'Incorrect email or password' }); + }); + }); + + describe('POST /v1/auth/logout', () => { + test('should return 204 if refresh token is valid', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); + const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); + await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH); + + await request(app).post('/v1/auth/logout').send({ refreshToken }).expect(httpStatus.NO_CONTENT); + + const dbRefreshTokenDoc = await Token.findOne({ token: refreshToken }); + expect(dbRefreshTokenDoc).toBe(null); + }); + + test('should return 400 error if refresh token is missing from request body', async () => { + await request(app).post('/v1/auth/logout').send().expect(httpStatus.BAD_REQUEST); + }); + + test('should return 404 error if refresh token is not found in the database', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); + const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); + + await request(app).post('/v1/auth/logout').send({ refreshToken }).expect(httpStatus.NOT_FOUND); + }); + + test('should return 404 error if refresh token is blacklisted', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); + const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); + await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH, true); + + await request(app).post('/v1/auth/logout').send({ refreshToken }).expect(httpStatus.NOT_FOUND); + }); + }); + + describe('POST /v1/auth/refresh-tokens', () => { + test('should return 200 and new auth tokens if refresh token is valid', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); + const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); + await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH); + + const res = await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.OK); + + expect(res.body).toEqual({ + access: { token: expect.anything(), expires: expect.anything() }, + refresh: { token: expect.anything(), expires: expect.anything() }, + }); + + const dbRefreshTokenDoc = await Token.findOne({ token: res.body.refresh.token }); + expect(dbRefreshTokenDoc).toMatchObject({ type: tokenTypes.REFRESH, user: userOne._id, blacklisted: false }); + + const dbRefreshTokenCount = await Token.countDocuments(); + expect(dbRefreshTokenCount).toBe(1); + }); + + test('should return 400 error if refresh token is missing from request body', async () => { + await request(app).post('/v1/auth/refresh-tokens').send().expect(httpStatus.BAD_REQUEST); + }); + + test('should return 401 error if refresh token is signed using an invalid secret', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); + const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH, 'invalidSecret'); + await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH); + + await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 401 error if refresh token is not found in the database', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); + const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); + + await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 401 error if refresh token is blacklisted', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); + const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); + await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH, true); + + await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 401 error if refresh token is expired', async () => { + await insertUsers([userOne]); + const expires = moment().subtract(1, 'minutes'); + const refreshToken = tokenService.generateToken(userOne._id, expires); + await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH); + + await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 401 error if user is not found', async () => { + const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); + const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); + await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH); + + await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.UNAUTHORIZED); + }); + }); + + describe('POST /v1/auth/forgot-password', () => { + beforeEach(() => { + jest.spyOn(emailService.transport, 'sendMail').mockResolvedValue(); + }); + + test('should return 204 and send reset password email to the user', async () => { + await insertUsers([userOne]); + const sendResetPasswordEmailSpy = jest.spyOn(emailService, 'sendResetPasswordEmail'); + + await request(app).post('/v1/auth/forgot-password').send({ email: userOne.email }).expect(httpStatus.NO_CONTENT); + + expect(sendResetPasswordEmailSpy).toHaveBeenCalledWith(userOne.email, expect.any(String)); + const resetPasswordToken = sendResetPasswordEmailSpy.mock.calls[0][1]; + const dbResetPasswordTokenDoc = await Token.findOne({ token: resetPasswordToken, user: userOne._id }); + expect(dbResetPasswordTokenDoc).toBeDefined(); + }); + + test('should return 400 if email is missing', async () => { + await insertUsers([userOne]); + + await request(app).post('/v1/auth/forgot-password').send().expect(httpStatus.BAD_REQUEST); + }); + + test('should return 404 if email does not belong to any user', async () => { + await request(app).post('/v1/auth/forgot-password').send({ email: userOne.email }).expect(httpStatus.NOT_FOUND); + }); + }); + + describe('POST /v1/auth/reset-password', () => { + test('should return 204 and reset the password', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); + const resetPasswordToken = tokenService.generateToken(userOne._id, expires, tokenTypes.RESET_PASSWORD); + await tokenService.saveToken(resetPasswordToken, userOne._id, expires, tokenTypes.RESET_PASSWORD); + + await request(app) + .post('/v1/auth/reset-password') + .query({ token: resetPasswordToken }) + .send({ password: 'password2' }) + .expect(httpStatus.NO_CONTENT); + + const dbUser = await User.findById(userOne._id); + const isPasswordMatch = await bcrypt.compare('password2', dbUser.password); + expect(isPasswordMatch).toBe(true); + + const dbResetPasswordTokenCount = await Token.countDocuments({ user: userOne._id, type: tokenTypes.RESET_PASSWORD }); + expect(dbResetPasswordTokenCount).toBe(0); + }); + + test('should return 400 if reset password token is missing', async () => { + await insertUsers([userOne]); + + await request(app).post('/v1/auth/reset-password').send({ password: 'password2' }).expect(httpStatus.BAD_REQUEST); + }); + + test('should return 401 if reset password token is blacklisted', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); + const resetPasswordToken = tokenService.generateToken(userOne._id, expires, tokenTypes.RESET_PASSWORD); + await tokenService.saveToken(resetPasswordToken, userOne._id, expires, tokenTypes.RESET_PASSWORD, true); + + await request(app) + .post('/v1/auth/reset-password') + .query({ token: resetPasswordToken }) + .send({ password: 'password2' }) + .expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 401 if reset password token is expired', async () => { + await insertUsers([userOne]); + const expires = moment().subtract(1, 'minutes'); + const resetPasswordToken = tokenService.generateToken(userOne._id, expires, tokenTypes.RESET_PASSWORD); + await tokenService.saveToken(resetPasswordToken, userOne._id, expires, tokenTypes.RESET_PASSWORD); + + await request(app) + .post('/v1/auth/reset-password') + .query({ token: resetPasswordToken }) + .send({ password: 'password2' }) + .expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 401 if user is not found', async () => { + const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); + const resetPasswordToken = tokenService.generateToken(userOne._id, expires, tokenTypes.RESET_PASSWORD); + await tokenService.saveToken(resetPasswordToken, userOne._id, expires, tokenTypes.RESET_PASSWORD); + + await request(app) + .post('/v1/auth/reset-password') + .query({ token: resetPasswordToken }) + .send({ password: 'password2' }) + .expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 400 if password is missing or invalid', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); + const resetPasswordToken = tokenService.generateToken(userOne._id, expires, tokenTypes.RESET_PASSWORD); + await tokenService.saveToken(resetPasswordToken, userOne._id, expires, tokenTypes.RESET_PASSWORD); + + await request(app).post('/v1/auth/reset-password').query({ token: resetPasswordToken }).expect(httpStatus.BAD_REQUEST); + + await request(app) + .post('/v1/auth/reset-password') + .query({ token: resetPasswordToken }) + .send({ password: 'short1' }) + .expect(httpStatus.BAD_REQUEST); + + await request(app) + .post('/v1/auth/reset-password') + .query({ token: resetPasswordToken }) + .send({ password: 'password' }) + .expect(httpStatus.BAD_REQUEST); + + await request(app) + .post('/v1/auth/reset-password') + .query({ token: resetPasswordToken }) + .send({ password: '11111111' }) + .expect(httpStatus.BAD_REQUEST); + }); + }); + + describe('POST /v1/auth/send-verification-email', () => { + beforeEach(() => { + jest.spyOn(emailService.transport, 'sendMail').mockResolvedValue(); + }); + + test('should return 204 and send verification email to the user', async () => { + await insertUsers([userOne]); + const sendVerificationEmailSpy = jest.spyOn(emailService, 'sendVerificationEmail'); + + await request(app) + .post('/v1/auth/send-verification-email') + .set('Authorization', `Bearer ${userOneAccessToken}`) + .expect(httpStatus.NO_CONTENT); + + expect(sendVerificationEmailSpy).toHaveBeenCalledWith(userOne.email, expect.any(String)); + const verifyEmailToken = sendVerificationEmailSpy.mock.calls[0][1]; + const dbVerifyEmailToken = await Token.findOne({ token: verifyEmailToken, user: userOne._id }); + + expect(dbVerifyEmailToken).toBeDefined(); + }); + + test('should return 401 error if access token is missing', async () => { + await insertUsers([userOne]); + + await request(app).post('/v1/auth/send-verification-email').send().expect(httpStatus.UNAUTHORIZED); + }); + }); + + describe('POST /v1/auth/verify-email', () => { + test('should return 204 and verify the email', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes'); + const verifyEmailToken = tokenService.generateToken(userOne._id, expires); + await tokenService.saveToken(verifyEmailToken, userOne._id, expires, tokenTypes.VERIFY_EMAIL); + + await request(app) + .post('/v1/auth/verify-email') + .query({ token: verifyEmailToken }) + .send() + .expect(httpStatus.NO_CONTENT); + + const dbUser = await User.findById(userOne._id); + + expect(dbUser.isEmailVerified).toBe(true); + + const dbVerifyEmailToken = await Token.countDocuments({ + user: userOne._id, + type: tokenTypes.VERIFY_EMAIL, + }); + expect(dbVerifyEmailToken).toBe(0); + }); + + test('should return 400 if verify email token is missing', async () => { + await insertUsers([userOne]); + + await request(app).post('/v1/auth/verify-email').send().expect(httpStatus.BAD_REQUEST); + }); + + test('should return 401 if verify email token is blacklisted', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes'); + const verifyEmailToken = tokenService.generateToken(userOne._id, expires); + await tokenService.saveToken(verifyEmailToken, userOne._id, expires, tokenTypes.VERIFY_EMAIL, true); + + await request(app) + .post('/v1/auth/verify-email') + .query({ token: verifyEmailToken }) + .send() + .expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 401 if verify email token is expired', async () => { + await insertUsers([userOne]); + const expires = moment().subtract(1, 'minutes'); + const verifyEmailToken = tokenService.generateToken(userOne._id, expires); + await tokenService.saveToken(verifyEmailToken, userOne._id, expires, tokenTypes.VERIFY_EMAIL); + + await request(app) + .post('/v1/auth/verify-email') + .query({ token: verifyEmailToken }) + .send() + .expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 401 if user is not found', async () => { + const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes'); + const verifyEmailToken = tokenService.generateToken(userOne._id, expires); + await tokenService.saveToken(verifyEmailToken, userOne._id, expires, tokenTypes.VERIFY_EMAIL); + + await request(app) + .post('/v1/auth/verify-email') + .query({ token: verifyEmailToken }) + .send() + .expect(httpStatus.UNAUTHORIZED); + }); + }); +}); + +describe('Auth middleware', () => { + test('should call next with no errors if access token is valid', async () => { + await insertUsers([userOne]); + const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${userOneAccessToken}` } }); + const next = jest.fn(); + + await auth()(req, httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(); + expect(req.user._id).toEqual(userOne._id); + }); + + test('should call next with unauthorized error if access token is not found in header', async () => { + await insertUsers([userOne]); + const req = httpMocks.createRequest(); + const next = jest.fn(); + + await auth()(req, httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(expect.any(ApiError)); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) + ); + }); + + test('should call next with unauthorized error if access token is not a valid jwt token', async () => { + await insertUsers([userOne]); + const req = httpMocks.createRequest({ headers: { Authorization: 'Bearer randomToken' } }); + const next = jest.fn(); + + await auth()(req, httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(expect.any(ApiError)); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) + ); + }); + + test('should call next with unauthorized error if the token is not an access token', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.accessExpirationMinutes, 'minutes'); + const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); + const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${refreshToken}` } }); + const next = jest.fn(); + + await auth()(req, httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(expect.any(ApiError)); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) + ); + }); + + test('should call next with unauthorized error if access token is generated with an invalid secret', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.accessExpirationMinutes, 'minutes'); + const accessToken = tokenService.generateToken(userOne._id, expires, tokenTypes.ACCESS, 'invalidSecret'); + const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${accessToken}` } }); + const next = jest.fn(); + + await auth()(req, httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(expect.any(ApiError)); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) + ); + }); + + test('should call next with unauthorized error if access token is expired', async () => { + await insertUsers([userOne]); + const expires = moment().subtract(1, 'minutes'); + const accessToken = tokenService.generateToken(userOne._id, expires, tokenTypes.ACCESS); + const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${accessToken}` } }); + const next = jest.fn(); + + await auth()(req, httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(expect.any(ApiError)); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) + ); + }); + + test('should call next with unauthorized error if user is not found', async () => { + const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${userOneAccessToken}` } }); + const next = jest.fn(); + + await auth()(req, httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(expect.any(ApiError)); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) + ); + }); + + test('should call next with forbidden error if user does not have required rights and userId is not in params', async () => { + await insertUsers([userOne]); + const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${userOneAccessToken}` } }); + const next = jest.fn(); + + await auth('anyRight')(req, httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(expect.any(ApiError)); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: httpStatus.FORBIDDEN, message: 'Forbidden' })); + }); + + test('should call next with no errors if user does not have required rights but userId is in params', async () => { + await insertUsers([userOne]); + const req = httpMocks.createRequest({ + headers: { Authorization: `Bearer ${userOneAccessToken}` }, + params: { userId: userOne._id.toHexString() }, + }); + const next = jest.fn(); + + await auth('anyRight')(req, httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(); + }); + + test('should call next with no errors if user has required rights', async () => { + await insertUsers([admin]); + const req = httpMocks.createRequest({ + headers: { Authorization: `Bearer ${adminAccessToken}` }, + params: { userId: userOne._id.toHexString() }, + }); + const next = jest.fn(); + + await auth(...roleRights.get('admin'))(req, httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(); + }); +}); diff --git a/tests/integration/docs.test.js b/tests/integration/docs.test.js new file mode 100644 index 0000000..7df9513 --- /dev/null +++ b/tests/integration/docs.test.js @@ -0,0 +1,14 @@ +const request = require('supertest'); +const httpStatus = require('http-status'); +const app = require('../../src/app'); +const config = require('../../src/config/config'); + +describe('Auth routes', () => { + describe('GET /v1/docs', () => { + test('should return 404 when running in production', async () => { + config.env = 'production'; + await request(app).get('/v1/docs').send().expect(httpStatus.NOT_FOUND); + config.env = process.env.NODE_ENV; + }); + }); +}); diff --git a/tests/integration/user.test.js b/tests/integration/user.test.js new file mode 100644 index 0000000..279e4ef --- /dev/null +++ b/tests/integration/user.test.js @@ -0,0 +1,625 @@ +const request = require('supertest'); +const faker = require('faker'); +const httpStatus = require('http-status'); +const app = require('../../src/app'); +const setupTestDB = require('../utils/setupTestDB'); +const { User } = require('../../src/models'); +const { userOne, userTwo, admin, insertUsers } = require('../fixtures/user.fixture'); +const { userOneAccessToken, adminAccessToken } = require('../fixtures/token.fixture'); + +setupTestDB(); + +describe('User routes', () => { + describe('POST /v1/users', () => { + let newUser; + + beforeEach(() => { + newUser = { + name: faker.name.findName(), + email: faker.internet.email().toLowerCase(), + password: 'password1', + role: 'user', + }; + }); + + test('should return 201 and successfully create new user if data is ok', async () => { + await insertUsers([admin]); + + const res = await request(app) + .post('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send(newUser) + .expect(httpStatus.CREATED); + + expect(res.body).not.toHaveProperty('password'); + expect(res.body).toEqual({ + id: expect.anything(), + name: newUser.name, + email: newUser.email, + role: newUser.role, + isEmailVerified: false, + }); + + const dbUser = await User.findById(res.body.id); + expect(dbUser).toBeDefined(); + expect(dbUser.password).not.toBe(newUser.password); + expect(dbUser).toMatchObject({ name: newUser.name, email: newUser.email, role: newUser.role, isEmailVerified: false }); + }); + + test('should be able to create an admin as well', async () => { + await insertUsers([admin]); + newUser.role = 'admin'; + + const res = await request(app) + .post('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send(newUser) + .expect(httpStatus.CREATED); + + expect(res.body.role).toBe('admin'); + + const dbUser = await User.findById(res.body.id); + expect(dbUser.role).toBe('admin'); + }); + + test('should return 401 error if access token is missing', async () => { + await request(app).post('/v1/users').send(newUser).expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 403 error if logged in user is not admin', async () => { + await insertUsers([userOne]); + + await request(app) + .post('/v1/users') + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send(newUser) + .expect(httpStatus.FORBIDDEN); + }); + + test('should return 400 error if email is invalid', async () => { + await insertUsers([admin]); + newUser.email = 'invalidEmail'; + + await request(app) + .post('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send(newUser) + .expect(httpStatus.BAD_REQUEST); + }); + + test('should return 400 error if email is already used', async () => { + await insertUsers([admin, userOne]); + newUser.email = userOne.email; + + await request(app) + .post('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send(newUser) + .expect(httpStatus.BAD_REQUEST); + }); + + test('should return 400 error if password length is less than 8 characters', async () => { + await insertUsers([admin]); + newUser.password = 'passwo1'; + + await request(app) + .post('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send(newUser) + .expect(httpStatus.BAD_REQUEST); + }); + + test('should return 400 error if password does not contain both letters and numbers', async () => { + await insertUsers([admin]); + newUser.password = 'password'; + + await request(app) + .post('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send(newUser) + .expect(httpStatus.BAD_REQUEST); + + newUser.password = '1111111'; + + await request(app) + .post('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send(newUser) + .expect(httpStatus.BAD_REQUEST); + }); + + test('should return 400 error if role is neither user nor admin', async () => { + await insertUsers([admin]); + newUser.role = 'invalid'; + + await request(app) + .post('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send(newUser) + .expect(httpStatus.BAD_REQUEST); + }); + }); + + describe('GET /v1/users', () => { + test('should return 200 and apply the default query options', async () => { + await insertUsers([userOne, userTwo, admin]); + + const res = await request(app) + .get('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send() + .expect(httpStatus.OK); + + expect(res.body).toEqual({ + results: expect.any(Array), + page: 1, + limit: 10, + totalPages: 1, + totalResults: 3, + }); + expect(res.body.results).toHaveLength(3); + expect(res.body.results[0]).toEqual({ + id: userOne._id.toHexString(), + name: userOne.name, + email: userOne.email, + role: userOne.role, + isEmailVerified: userOne.isEmailVerified, + }); + }); + + test('should return 401 if access token is missing', async () => { + await insertUsers([userOne, userTwo, admin]); + + await request(app).get('/v1/users').send().expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 403 if a non-admin is trying to access all users', async () => { + await insertUsers([userOne, userTwo, admin]); + + await request(app) + .get('/v1/users') + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send() + .expect(httpStatus.FORBIDDEN); + }); + + test('should correctly apply filter on name field', async () => { + await insertUsers([userOne, userTwo, admin]); + + const res = await request(app) + .get('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .query({ name: userOne.name }) + .send() + .expect(httpStatus.OK); + + expect(res.body).toEqual({ + results: expect.any(Array), + page: 1, + limit: 10, + totalPages: 1, + totalResults: 1, + }); + expect(res.body.results).toHaveLength(1); + expect(res.body.results[0].id).toBe(userOne._id.toHexString()); + }); + + test('should correctly apply filter on role field', async () => { + await insertUsers([userOne, userTwo, admin]); + + const res = await request(app) + .get('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .query({ role: 'user' }) + .send() + .expect(httpStatus.OK); + + expect(res.body).toEqual({ + results: expect.any(Array), + page: 1, + limit: 10, + totalPages: 1, + totalResults: 2, + }); + expect(res.body.results).toHaveLength(2); + expect(res.body.results[0].id).toBe(userOne._id.toHexString()); + expect(res.body.results[1].id).toBe(userTwo._id.toHexString()); + }); + + test('should correctly sort the returned array if descending sort param is specified', async () => { + await insertUsers([userOne, userTwo, admin]); + + const res = await request(app) + .get('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .query({ sortBy: 'role:desc' }) + .send() + .expect(httpStatus.OK); + + expect(res.body).toEqual({ + results: expect.any(Array), + page: 1, + limit: 10, + totalPages: 1, + totalResults: 3, + }); + expect(res.body.results).toHaveLength(3); + expect(res.body.results[0].id).toBe(userOne._id.toHexString()); + expect(res.body.results[1].id).toBe(userTwo._id.toHexString()); + expect(res.body.results[2].id).toBe(admin._id.toHexString()); + }); + + test('should correctly sort the returned array if ascending sort param is specified', async () => { + await insertUsers([userOne, userTwo, admin]); + + const res = await request(app) + .get('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .query({ sortBy: 'role:asc' }) + .send() + .expect(httpStatus.OK); + + expect(res.body).toEqual({ + results: expect.any(Array), + page: 1, + limit: 10, + totalPages: 1, + totalResults: 3, + }); + expect(res.body.results).toHaveLength(3); + expect(res.body.results[0].id).toBe(admin._id.toHexString()); + expect(res.body.results[1].id).toBe(userOne._id.toHexString()); + expect(res.body.results[2].id).toBe(userTwo._id.toHexString()); + }); + + test('should correctly sort the returned array if multiple sorting criteria are specified', async () => { + await insertUsers([userOne, userTwo, admin]); + + const res = await request(app) + .get('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .query({ sortBy: 'role:desc,name:asc' }) + .send() + .expect(httpStatus.OK); + + expect(res.body).toEqual({ + results: expect.any(Array), + page: 1, + limit: 10, + totalPages: 1, + totalResults: 3, + }); + expect(res.body.results).toHaveLength(3); + + const expectedOrder = [userOne, userTwo, admin].sort((a, b) => { + if (a.role < b.role) { + return 1; + } + if (a.role > b.role) { + return -1; + } + return a.name < b.name ? -1 : 1; + }); + + expectedOrder.forEach((user, index) => { + expect(res.body.results[index].id).toBe(user._id.toHexString()); + }); + }); + + test('should limit returned array if limit param is specified', async () => { + await insertUsers([userOne, userTwo, admin]); + + const res = await request(app) + .get('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .query({ limit: 2 }) + .send() + .expect(httpStatus.OK); + + expect(res.body).toEqual({ + results: expect.any(Array), + page: 1, + limit: 2, + totalPages: 2, + totalResults: 3, + }); + expect(res.body.results).toHaveLength(2); + expect(res.body.results[0].id).toBe(userOne._id.toHexString()); + expect(res.body.results[1].id).toBe(userTwo._id.toHexString()); + }); + + test('should return the correct page if page and limit params are specified', async () => { + await insertUsers([userOne, userTwo, admin]); + + const res = await request(app) + .get('/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .query({ page: 2, limit: 2 }) + .send() + .expect(httpStatus.OK); + + expect(res.body).toEqual({ + results: expect.any(Array), + page: 2, + limit: 2, + totalPages: 2, + totalResults: 3, + }); + expect(res.body.results).toHaveLength(1); + expect(res.body.results[0].id).toBe(admin._id.toHexString()); + }); + }); + + describe('GET /v1/users/:userId', () => { + test('should return 200 and the user object if data is ok', async () => { + await insertUsers([userOne]); + + const res = await request(app) + .get(`/v1/users/${userOne._id}`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send() + .expect(httpStatus.OK); + + expect(res.body).not.toHaveProperty('password'); + expect(res.body).toEqual({ + id: userOne._id.toHexString(), + email: userOne.email, + name: userOne.name, + role: userOne.role, + isEmailVerified: userOne.isEmailVerified, + }); + }); + + test('should return 401 error if access token is missing', async () => { + await insertUsers([userOne]); + + await request(app).get(`/v1/users/${userOne._id}`).send().expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 403 error if user is trying to get another user', async () => { + await insertUsers([userOne, userTwo]); + + await request(app) + .get(`/v1/users/${userTwo._id}`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send() + .expect(httpStatus.FORBIDDEN); + }); + + test('should return 200 and the user object if admin is trying to get another user', async () => { + await insertUsers([userOne, admin]); + + await request(app) + .get(`/v1/users/${userOne._id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send() + .expect(httpStatus.OK); + }); + + test('should return 400 error if userId is not a valid mongo id', async () => { + await insertUsers([admin]); + + await request(app) + .get('/v1/users/invalidId') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send() + .expect(httpStatus.BAD_REQUEST); + }); + + test('should return 404 error if user is not found', async () => { + await insertUsers([admin]); + + await request(app) + .get(`/v1/users/${userOne._id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send() + .expect(httpStatus.NOT_FOUND); + }); + }); + + describe('DELETE /v1/users/:userId', () => { + test('should return 204 if data is ok', async () => { + await insertUsers([userOne]); + + await request(app) + .delete(`/v1/users/${userOne._id}`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send() + .expect(httpStatus.NO_CONTENT); + + const dbUser = await User.findById(userOne._id); + expect(dbUser).toBeNull(); + }); + + test('should return 401 error if access token is missing', async () => { + await insertUsers([userOne]); + + await request(app).delete(`/v1/users/${userOne._id}`).send().expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 403 error if user is trying to delete another user', async () => { + await insertUsers([userOne, userTwo]); + + await request(app) + .delete(`/v1/users/${userTwo._id}`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send() + .expect(httpStatus.FORBIDDEN); + }); + + test('should return 204 if admin is trying to delete another user', async () => { + await insertUsers([userOne, admin]); + + await request(app) + .delete(`/v1/users/${userOne._id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send() + .expect(httpStatus.NO_CONTENT); + }); + + test('should return 400 error if userId is not a valid mongo id', async () => { + await insertUsers([admin]); + + await request(app) + .delete('/v1/users/invalidId') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send() + .expect(httpStatus.BAD_REQUEST); + }); + + test('should return 404 error if user already is not found', async () => { + await insertUsers([admin]); + + await request(app) + .delete(`/v1/users/${userOne._id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send() + .expect(httpStatus.NOT_FOUND); + }); + }); + + describe('PATCH /v1/users/:userId', () => { + test('should return 200 and successfully update user if data is ok', async () => { + await insertUsers([userOne]); + const updateBody = { + name: faker.name.findName(), + email: faker.internet.email().toLowerCase(), + password: 'newPassword1', + }; + + const res = await request(app) + .patch(`/v1/users/${userOne._id}`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send(updateBody) + .expect(httpStatus.OK); + + expect(res.body).not.toHaveProperty('password'); + expect(res.body).toEqual({ + id: userOne._id.toHexString(), + name: updateBody.name, + email: updateBody.email, + role: 'user', + isEmailVerified: false, + }); + + const dbUser = await User.findById(userOne._id); + expect(dbUser).toBeDefined(); + expect(dbUser.password).not.toBe(updateBody.password); + expect(dbUser).toMatchObject({ name: updateBody.name, email: updateBody.email, role: 'user' }); + }); + + test('should return 401 error if access token is missing', async () => { + await insertUsers([userOne]); + const updateBody = { name: faker.name.findName() }; + + await request(app).patch(`/v1/users/${userOne._id}`).send(updateBody).expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 403 if user is updating another user', async () => { + await insertUsers([userOne, userTwo]); + const updateBody = { name: faker.name.findName() }; + + await request(app) + .patch(`/v1/users/${userTwo._id}`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send(updateBody) + .expect(httpStatus.FORBIDDEN); + }); + + test('should return 200 and successfully update user if admin is updating another user', async () => { + await insertUsers([userOne, admin]); + const updateBody = { name: faker.name.findName() }; + + await request(app) + .patch(`/v1/users/${userOne._id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send(updateBody) + .expect(httpStatus.OK); + }); + + test('should return 404 if admin is updating another user that is not found', async () => { + await insertUsers([admin]); + const updateBody = { name: faker.name.findName() }; + + await request(app) + .patch(`/v1/users/${userOne._id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send(updateBody) + .expect(httpStatus.NOT_FOUND); + }); + + test('should return 400 error if userId is not a valid mongo id', async () => { + await insertUsers([admin]); + const updateBody = { name: faker.name.findName() }; + + await request(app) + .patch(`/v1/users/invalidId`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send(updateBody) + .expect(httpStatus.BAD_REQUEST); + }); + + test('should return 400 if email is invalid', async () => { + await insertUsers([userOne]); + const updateBody = { email: 'invalidEmail' }; + + await request(app) + .patch(`/v1/users/${userOne._id}`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send(updateBody) + .expect(httpStatus.BAD_REQUEST); + }); + + test('should return 400 if email is already taken', async () => { + await insertUsers([userOne, userTwo]); + const updateBody = { email: userTwo.email }; + + await request(app) + .patch(`/v1/users/${userOne._id}`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send(updateBody) + .expect(httpStatus.BAD_REQUEST); + }); + + test('should not return 400 if email is my email', async () => { + await insertUsers([userOne]); + const updateBody = { email: userOne.email }; + + await request(app) + .patch(`/v1/users/${userOne._id}`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send(updateBody) + .expect(httpStatus.OK); + }); + + test('should return 400 if password length is less than 8 characters', async () => { + await insertUsers([userOne]); + const updateBody = { password: 'passwo1' }; + + await request(app) + .patch(`/v1/users/${userOne._id}`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send(updateBody) + .expect(httpStatus.BAD_REQUEST); + }); + + test('should return 400 if password does not contain both letters and numbers', async () => { + await insertUsers([userOne]); + const updateBody = { password: 'password' }; + + await request(app) + .patch(`/v1/users/${userOne._id}`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send(updateBody) + .expect(httpStatus.BAD_REQUEST); + + updateBody.password = '11111111'; + + await request(app) + .patch(`/v1/users/${userOne._id}`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send(updateBody) + .expect(httpStatus.BAD_REQUEST); + }); + }); +}); diff --git a/tests/unit/middlewares/error.test.js b/tests/unit/middlewares/error.test.js new file mode 100644 index 0000000..c17a7d4 --- /dev/null +++ b/tests/unit/middlewares/error.test.js @@ -0,0 +1,168 @@ +const mongoose = require('mongoose'); +const httpStatus = require('http-status'); +const httpMocks = require('node-mocks-http'); +const { errorConverter, errorHandler } = require('../../../src/middlewares/error'); +const ApiError = require('../../../src/utils/ApiError'); +const config = require('../../../src/config/config'); +const logger = require('../../../src/config/logger'); + +describe('Error middlewares', () => { + describe('Error converter', () => { + test('should return the same ApiError object it was called with', () => { + const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); + const next = jest.fn(); + + errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(error); + }); + + test('should convert an Error to ApiError and preserve its status and message', () => { + const error = new Error('Any error'); + error.statusCode = httpStatus.BAD_REQUEST; + const next = jest.fn(); + + errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(expect.any(ApiError)); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: error.statusCode, + message: error.message, + isOperational: false, + }) + ); + }); + + test('should convert an Error without status to ApiError with status 500', () => { + const error = new Error('Any error'); + const next = jest.fn(); + + errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(expect.any(ApiError)); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: httpStatus.INTERNAL_SERVER_ERROR, + message: error.message, + isOperational: false, + }) + ); + }); + + test('should convert an Error without message to ApiError with default message of that http status', () => { + const error = new Error(); + error.statusCode = httpStatus.BAD_REQUEST; + const next = jest.fn(); + + errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(expect.any(ApiError)); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: error.statusCode, + message: httpStatus[error.statusCode], + isOperational: false, + }) + ); + }); + + test('should convert a Mongoose error to ApiError with status 400 and preserve its message', () => { + const error = new mongoose.Error('Any mongoose error'); + const next = jest.fn(); + + errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(expect.any(ApiError)); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: httpStatus.BAD_REQUEST, + message: error.message, + isOperational: false, + }) + ); + }); + + test('should convert any other object to ApiError with status 500 and its message', () => { + const error = {}; + const next = jest.fn(); + + errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); + + expect(next).toHaveBeenCalledWith(expect.any(ApiError)); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: httpStatus.INTERNAL_SERVER_ERROR, + message: httpStatus[httpStatus.INTERNAL_SERVER_ERROR], + isOperational: false, + }) + ); + }); + }); + + describe('Error handler', () => { + beforeEach(() => { + jest.spyOn(logger, 'error').mockImplementation(() => {}); + }); + + test('should send proper error response and put the error message in res.locals', () => { + const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); + const res = httpMocks.createResponse(); + const sendSpy = jest.spyOn(res, 'send'); + + errorHandler(error, httpMocks.createRequest(), res); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ code: error.statusCode, message: error.message })); + expect(res.locals.errorMessage).toBe(error.message); + }); + + test('should put the error stack in the response if in development mode', () => { + config.env = 'development'; + const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); + const res = httpMocks.createResponse(); + const sendSpy = jest.spyOn(res, 'send'); + + errorHandler(error, httpMocks.createRequest(), res); + + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ code: error.statusCode, message: error.message, stack: error.stack }) + ); + config.env = process.env.NODE_ENV; + }); + + test('should send internal server error status and message if in production mode and error is not operational', () => { + config.env = 'production'; + const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error', false); + const res = httpMocks.createResponse(); + const sendSpy = jest.spyOn(res, 'send'); + + errorHandler(error, httpMocks.createRequest(), res); + + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: httpStatus.INTERNAL_SERVER_ERROR, + message: httpStatus[httpStatus.INTERNAL_SERVER_ERROR], + }) + ); + expect(res.locals.errorMessage).toBe(error.message); + config.env = process.env.NODE_ENV; + }); + + test('should preserve original error status and message if in production mode and error is operational', () => { + config.env = 'production'; + const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); + const res = httpMocks.createResponse(); + const sendSpy = jest.spyOn(res, 'send'); + + errorHandler(error, httpMocks.createRequest(), res); + + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: error.statusCode, + message: error.message, + }) + ); + config.env = process.env.NODE_ENV; + }); + }); +}); diff --git a/tests/unit/models/plugins/paginate.plugin.test.js b/tests/unit/models/plugins/paginate.plugin.test.js new file mode 100644 index 0000000..b6af0df --- /dev/null +++ b/tests/unit/models/plugins/paginate.plugin.test.js @@ -0,0 +1,61 @@ +const mongoose = require('mongoose'); +const setupTestDB = require('../../../utils/setupTestDB'); +const paginate = require('../../../../src/models/plugins/paginate.plugin'); + +const projectSchema = mongoose.Schema({ + name: { + type: String, + required: true, + }, +}); + +projectSchema.virtual('tasks', { + ref: 'Task', + localField: '_id', + foreignField: 'project', +}); + +projectSchema.plugin(paginate); +const Project = mongoose.model('Project', projectSchema); + +const taskSchema = mongoose.Schema({ + name: { + type: String, + required: true, + }, + project: { + type: mongoose.SchemaTypes.ObjectId, + ref: 'Project', + required: true, + }, +}); + +taskSchema.plugin(paginate); +const Task = mongoose.model('Task', taskSchema); + +setupTestDB(); + +describe('paginate plugin', () => { + describe('populate option', () => { + test('should populate the specified data fields', async () => { + const project = await Project.create({ name: 'Project One' }); + const task = await Task.create({ name: 'Task One', project: project._id }); + + const taskPages = await Task.paginate({ _id: task._id }, { populate: 'project' }); + + expect(taskPages.results[0].project).toHaveProperty('_id', project._id); + }); + + test('should populate nested fields', async () => { + const project = await Project.create({ name: 'Project One' }); + const task = await Task.create({ name: 'Task One', project: project._id }); + + const projectPages = await Project.paginate({ _id: project._id }, { populate: 'tasks.project' }); + const { tasks } = projectPages.results[0]; + + expect(tasks).toHaveLength(1); + expect(tasks[0]).toHaveProperty('_id', task._id); + expect(tasks[0].project).toHaveProperty('_id', project._id); + }); + }); +}); diff --git a/tests/unit/models/plugins/toJSON.plugin.test.js b/tests/unit/models/plugins/toJSON.plugin.test.js new file mode 100644 index 0000000..7931828 --- /dev/null +++ b/tests/unit/models/plugins/toJSON.plugin.test.js @@ -0,0 +1,89 @@ +const mongoose = require('mongoose'); +const { toJSON } = require('../../../../src/models/plugins'); + +describe('toJSON plugin', () => { + let connection; + + beforeEach(() => { + connection = mongoose.createConnection(); + }); + + it('should replace _id with id', () => { + const schema = mongoose.Schema(); + schema.plugin(toJSON); + const Model = connection.model('Model', schema); + const doc = new Model(); + expect(doc.toJSON()).not.toHaveProperty('_id'); + expect(doc.toJSON()).toHaveProperty('id', doc._id.toString()); + }); + + it('should remove __v', () => { + const schema = mongoose.Schema(); + schema.plugin(toJSON); + const Model = connection.model('Model', schema); + const doc = new Model(); + expect(doc.toJSON()).not.toHaveProperty('__v'); + }); + + it('should remove createdAt and updatedAt', () => { + const schema = mongoose.Schema({}, { timestamps: true }); + schema.plugin(toJSON); + const Model = connection.model('Model', schema); + const doc = new Model(); + expect(doc.toJSON()).not.toHaveProperty('createdAt'); + expect(doc.toJSON()).not.toHaveProperty('updatedAt'); + }); + + it('should remove any path set as private', () => { + const schema = mongoose.Schema({ + public: { type: String }, + private: { type: String, private: true }, + }); + schema.plugin(toJSON); + const Model = connection.model('Model', schema); + const doc = new Model({ public: 'some public value', private: 'some private value' }); + expect(doc.toJSON()).not.toHaveProperty('private'); + expect(doc.toJSON()).toHaveProperty('public'); + }); + + it('should remove any nested paths set as private', () => { + const schema = mongoose.Schema({ + public: { type: String }, + nested: { + private: { type: String, private: true }, + }, + }); + schema.plugin(toJSON); + const Model = connection.model('Model', schema); + const doc = new Model({ + public: 'some public value', + nested: { + private: 'some nested private value', + }, + }); + expect(doc.toJSON()).not.toHaveProperty('nested.private'); + expect(doc.toJSON()).toHaveProperty('public'); + }); + + it('should also call the schema toJSON transform function', () => { + const schema = mongoose.Schema( + { + public: { type: String }, + private: { type: String }, + }, + { + toJSON: { + transform: (doc, ret) => { + // eslint-disable-next-line no-param-reassign + delete ret.private; + }, + }, + } + ); + schema.plugin(toJSON); + const Model = connection.model('Model', schema); + const doc = new Model({ public: 'some public value', private: 'some private value' }); + expect(doc.toJSON()).not.toHaveProperty('private'); + expect(doc.toJSON()).toHaveProperty('public'); + }); +}); diff --git a/tests/unit/models/user.model.test.js b/tests/unit/models/user.model.test.js new file mode 100644 index 0000000..8045d0e --- /dev/null +++ b/tests/unit/models/user.model.test.js @@ -0,0 +1,57 @@ +const faker = require('faker'); +const { User } = require('../../../src/models'); + +describe('User model', () => { + describe('User validation', () => { + let newUser; + beforeEach(() => { + newUser = { + name: faker.name.findName(), + email: faker.internet.email().toLowerCase(), + password: 'password1', + role: 'user', + }; + }); + + test('should correctly validate a valid user', async () => { + await expect(new User(newUser).validate()).resolves.toBeUndefined(); + }); + + test('should throw a validation error if email is invalid', async () => { + newUser.email = 'invalidEmail'; + await expect(new User(newUser).validate()).rejects.toThrow(); + }); + + test('should throw a validation error if password length is less than 8 characters', async () => { + newUser.password = 'passwo1'; + await expect(new User(newUser).validate()).rejects.toThrow(); + }); + + test('should throw a validation error if password does not contain numbers', async () => { + newUser.password = 'password'; + await expect(new User(newUser).validate()).rejects.toThrow(); + }); + + test('should throw a validation error if password does not contain letters', async () => { + newUser.password = '11111111'; + await expect(new User(newUser).validate()).rejects.toThrow(); + }); + + test('should throw a validation error if role is unknown', async () => { + newUser.role = 'invalid'; + await expect(new User(newUser).validate()).rejects.toThrow(); + }); + }); + + describe('User toJSON()', () => { + test('should not return user password when toJSON is called', () => { + const newUser = { + name: faker.name.findName(), + email: faker.internet.email().toLowerCase(), + password: 'password1', + role: 'user', + }; + expect(new User(newUser).toJSON()).not.toHaveProperty('password'); + }); + }); +}); diff --git a/tests/utils/setupTestDB.js b/tests/utils/setupTestDB.js new file mode 100644 index 0000000..77b786a --- /dev/null +++ b/tests/utils/setupTestDB.js @@ -0,0 +1,18 @@ +const mongoose = require('mongoose'); +const config = require('../../src/config/config'); + +const setupTestDB = () => { + beforeAll(async () => { + await mongoose.connect(config.mongoose.url, config.mongoose.options); + }); + + beforeEach(async () => { + await Promise.all(Object.values(mongoose.connection.collections).map(async (collection) => collection.deleteMany())); + }); + + afterAll(async () => { + await mongoose.disconnect(); + }); +}; + +module.exports = setupTestDB;