From 2677abe35ff0831d0f7094ac2ea0a77c08e2fbbc Mon Sep 17 00:00:00 2001 From: Kar l5 Date: Wed, 7 Aug 2024 21:43:47 +0530 Subject: [PATCH] init --- .dockerignore | 3 + .editorconfig | 9 + .env.example | 39 ++ .eslintignore | 2 + .eslintrc.json | 19 + .gitattributes | 3 + .gitignore | 12 + .husky/post-checkout | 4 + .husky/post-commit | 4 + .husky/pre-commit | 4 + .lintstagedrc.json | 3 + .prettierignore | 3 + .prettierrc.json | 4 + .travis.yml | 20 + Dockerfile | 15 + README.md | 433 ++++++++++++ bin/createNodejsApp.js | 111 ++++ docker-compose.dev.yml | 6 + docker-compose.prod.yml | 6 + docker-compose.test.yml | 6 + docker-compose.yml | 32 + ecosystem.config.json | 15 + jest.config.js | 9 + package.json | 96 +++ src/app.js | 88 +++ src/config/config.js | 64 ++ src/config/logger.js | 26 + src/config/morgan.js | 24 + src/config/passport.js | 30 + src/config/roles.js | 12 + src/config/tokens.js | 10 + src/controllers/auth.controller.js | 59 ++ src/controllers/index.js | 2 + src/controllers/user.controller.js | 43 ++ src/docs/components.yml | 92 +++ src/docs/swaggerDef.js | 21 + src/handlers/pingTest.js | 5 + src/index.js | 38 ++ src/middlewares/auth.js | 31 + src/middlewares/error.js | 44 ++ src/middlewares/rateLimiter.js | 11 + src/middlewares/validate.js | 21 + src/models/index.js | 2 + src/models/plugins/index.js | 2 + src/models/plugins/paginate.plugin.js | 70 ++ src/models/plugins/toJSON.plugin.js | 43 ++ src/models/token.model.js | 44 ++ src/models/user.model.js | 91 +++ src/routes/api/apiTest.js | 17 + src/routes/api/classMates.js | 149 +++++ src/routes/api/continueLearning.js | 70 ++ src/routes/api/generateQuestions.js | 35 + src/routes/api/getGameScore.js | 32 + src/routes/api/knowledgeQuests.js | 63 ++ src/routes/api/knowledgeQuestsAllContent.js | 180 +++++ src/routes/api/knowledgeQuestsCompleted.js | 180 +++++ src/routes/api/newQuestion.js | 34 + src/routes/api/newQuiz.js | 34 + src/routes/api/questionList.js | 182 +++++ src/routes/api/quizList.js | 33 + src/routes/api/quizModuleData.js | 419 ++++++++++++ src/routes/api/quizModuleList copy.js | 34 + src/routes/api/quizModuleList.js | 34 + src/routes/api/quizNewModule.js | 33 + src/routes/api/quizzesScore.js | 176 +++++ src/routes/api/resultAfterQuizSubmit.js | 93 +++ src/routes/api/saveGameScore.js | 66 ++ src/routes/api/savePostData.js | 33 + src/routes/api/saveQuizResponse.js | 59 ++ src/routes/api/topPerformers.js | 231 +++++++ src/routes/v1/api.route.js | 378 +++++++++++ src/routes/v1/auth.route.js | 291 ++++++++ src/routes/v1/docs.route.js | 21 + src/routes/v1/index.js | 44 ++ src/routes/v1/user.route.js | 252 +++++++ src/services/auth.service.js | 99 +++ src/services/email.service.js | 63 ++ src/services/index.js | 4 + src/services/token.service.js | 123 ++++ src/services/user.service.js | 89 +++ src/utils/ApiError.js | 14 + src/utils/catchAsync.js | 5 + src/utils/pick.js | 17 + src/validations/auth.validation.js | 60 ++ src/validations/custom.validation.js | 21 + src/validations/index.js | 2 + src/validations/user.validation.js | 54 ++ tests/fixtures/token.fixture.js | 14 + tests/fixtures/user.fixture.js | 46 ++ tests/integration/auth.test.js | 587 ++++++++++++++++ tests/integration/docs.test.js | 14 + tests/integration/user.test.js | 625 ++++++++++++++++++ tests/unit/middlewares/error.test.js | 168 +++++ .../models/plugins/paginate.plugin.test.js | 61 ++ .../unit/models/plugins/toJSON.plugin.test.js | 89 +++ tests/unit/models/user.model.test.js | 57 ++ tests/utils/setupTestDB.js | 18 + 97 files changed, 7134 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .eslintignore create mode 100644 .eslintrc.json create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .husky/post-checkout create mode 100644 .husky/post-commit create mode 100644 .husky/pre-commit create mode 100644 .lintstagedrc.json create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 .travis.yml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bin/createNodejsApp.js create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.test.yml create mode 100644 docker-compose.yml create mode 100644 ecosystem.config.json create mode 100644 jest.config.js create mode 100644 package.json create mode 100644 src/app.js create mode 100644 src/config/config.js create mode 100644 src/config/logger.js create mode 100644 src/config/morgan.js create mode 100644 src/config/passport.js create mode 100644 src/config/roles.js create mode 100644 src/config/tokens.js create mode 100644 src/controllers/auth.controller.js create mode 100644 src/controllers/index.js create mode 100644 src/controllers/user.controller.js create mode 100644 src/docs/components.yml create mode 100644 src/docs/swaggerDef.js create mode 100644 src/handlers/pingTest.js create mode 100644 src/index.js create mode 100644 src/middlewares/auth.js create mode 100644 src/middlewares/error.js create mode 100644 src/middlewares/rateLimiter.js create mode 100644 src/middlewares/validate.js create mode 100644 src/models/index.js create mode 100644 src/models/plugins/index.js create mode 100644 src/models/plugins/paginate.plugin.js create mode 100644 src/models/plugins/toJSON.plugin.js create mode 100644 src/models/token.model.js create mode 100644 src/models/user.model.js create mode 100644 src/routes/api/apiTest.js create mode 100644 src/routes/api/classMates.js create mode 100644 src/routes/api/continueLearning.js create mode 100644 src/routes/api/generateQuestions.js create mode 100644 src/routes/api/getGameScore.js create mode 100644 src/routes/api/knowledgeQuests.js create mode 100644 src/routes/api/knowledgeQuestsAllContent.js create mode 100644 src/routes/api/knowledgeQuestsCompleted.js create mode 100644 src/routes/api/newQuestion.js create mode 100644 src/routes/api/newQuiz.js create mode 100644 src/routes/api/questionList.js create mode 100644 src/routes/api/quizList.js create mode 100644 src/routes/api/quizModuleData.js create mode 100644 src/routes/api/quizModuleList copy.js create mode 100644 src/routes/api/quizModuleList.js create mode 100644 src/routes/api/quizNewModule.js create mode 100644 src/routes/api/quizzesScore.js create mode 100644 src/routes/api/resultAfterQuizSubmit.js create mode 100644 src/routes/api/saveGameScore.js create mode 100644 src/routes/api/savePostData.js create mode 100644 src/routes/api/saveQuizResponse.js create mode 100644 src/routes/api/topPerformers.js create mode 100644 src/routes/v1/api.route.js create mode 100644 src/routes/v1/auth.route.js create mode 100644 src/routes/v1/docs.route.js create mode 100644 src/routes/v1/index.js create mode 100644 src/routes/v1/user.route.js create mode 100644 src/services/auth.service.js create mode 100644 src/services/email.service.js create mode 100644 src/services/index.js create mode 100644 src/services/token.service.js create mode 100644 src/services/user.service.js create mode 100644 src/utils/ApiError.js create mode 100644 src/utils/catchAsync.js create mode 100644 src/utils/pick.js create mode 100644 src/validations/auth.validation.js create mode 100644 src/validations/custom.validation.js create mode 100644 src/validations/index.js create mode 100644 src/validations/user.validation.js create mode 100644 tests/fixtures/token.fixture.js create mode 100644 tests/fixtures/user.fixture.js create mode 100644 tests/integration/auth.test.js create mode 100644 tests/integration/docs.test.js create mode 100644 tests/integration/user.test.js create mode 100644 tests/unit/middlewares/error.test.js create mode 100644 tests/unit/models/plugins/paginate.plugin.test.js create mode 100644 tests/unit/models/plugins/toJSON.plugin.test.js create mode 100644 tests/unit/models/user.model.test.js create mode 100644 tests/utils/setupTestDB.js 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;