Kar l5 2024-08-07 21:43:47 +05:30
commit 2677abe35f
97 changed files with 7134 additions and 0 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
.git
.gitignore

9
.editorconfig Normal file
View File

@ -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

39
.env.example Normal file
View File

@ -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

2
.eslintignore Normal file
View File

@ -0,0 +1,2 @@
node_modules
bin

19
.eslintrc.json Normal file
View File

@ -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"
}
}

3
.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
# Convert text file line endings to lf
* text eol=lf
*.js text

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
# Dependencies
node_modules
# yarn error logs
yarn-error.log
# Environment varibales
.env*
!.env*.example
# Code coverage
coverage

4
.husky/post-checkout Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn install

4
.husky/post-commit Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
git status

4
.husky/pre-commit Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn lint-staged

3
.lintstagedrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"*.js": "eslint"
}

3
.prettierignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
coverage

4
.prettierrc.json Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"printWidth": 125
}

20
.travis.yml Normal file
View File

@ -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

15
Dockerfile Normal file
View File

@ -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

433
README.md Normal file
View File

@ -0,0 +1,433 @@
# RESTful API Node Server Boilerplate
## Quick Start
To create a project, simply run:
```bash
npx create-nodejs-express-app <project-name>
```
Or
```bash
npm init nodejs-express-app <project-name>
```
## 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('<path to src>/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)

111
bin/createNodejsApp.js Normal file
View File

@ -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();

6
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,6 @@
version: '3'
services:
node-app:
container_name: node-app-dev
command: yarn dev -L

6
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,6 @@
version: '3'
services:
node-app:
container_name: node-app-prod
command: yarn start

6
docker-compose.test.yml Normal file
View File

@ -0,0 +1,6 @@
version: '3'
services:
node-app:
container_name: node-app-test
command: yarn test

32
docker-compose.yml Normal file
View File

@ -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

15
ecosystem.config.json Normal file
View File

@ -0,0 +1,15 @@
{
"apps": [
{
"name": "app",
"script": "src/index.js",
"instances": 1,
"autorestart": true,
"watch": false,
"time": true,
"env": {
"NODE_ENV": "production"
}
}
]
}

9
jest.config.js Normal file
View File

@ -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'],
};

96
package.json Normal file
View File

@ -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"
}
}

88
src/app.js Normal file
View File

@ -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;

64
src/config/config.js Normal file
View File

@ -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,
},
};

26
src/config/logger.js Normal file
View File

@ -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;

24
src/config/morgan.js Normal file
View File

@ -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,
};

30
src/config/passport.js Normal file
View File

@ -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,
};

12
src/config/roles.js Normal file
View File

@ -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,
};

10
src/config/tokens.js Normal file
View File

@ -0,0 +1,10 @@
const tokenTypes = {
ACCESS: 'access',
REFRESH: 'refresh',
RESET_PASSWORD: 'resetPassword',
VERIFY_EMAIL: 'verifyEmail',
};
module.exports = {
tokenTypes,
};

View File

@ -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,
};

2
src/controllers/index.js Normal file
View File

@ -0,0 +1,2 @@
module.exports.authController = require('./auth.controller');
module.exports.userController = require('./user.controller');

View File

@ -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,
};

92
src/docs/components.yml Normal file
View File

@ -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

21
src/docs/swaggerDef.js Normal file
View File

@ -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;

5
src/handlers/pingTest.js Normal file
View File

@ -0,0 +1,5 @@
const homeHandler = (req, res) => {
res.send("Pong");
};
export default homeHandler;

38
src/index.js Normal file
View File

@ -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();
}
});

31
src/middlewares/auth.js Normal file
View File

@ -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;

44
src/middlewares/error.js Normal file
View File

@ -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,
};

View File

@ -0,0 +1,11 @@
const rateLimit = require('express-rate-limit');
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
skipSuccessfulRequests: true,
});
module.exports = {
authLimiter,
};

View File

@ -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;

2
src/models/index.js Normal file
View File

@ -0,0 +1,2 @@
module.exports.Token = require('./token.model');
module.exports.User = require('./user.model');

View File

@ -0,0 +1,2 @@
module.exports.toJSON = require('./toJSON.plugin');
module.exports.paginate = require('./paginate.plugin');

View File

@ -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<QueryResult>}
*/
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;

View File

@ -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;

44
src/models/token.model.js Normal file
View File

@ -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;

91
src/models/user.model.js Normal file
View File

@ -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<boolean>}
*/
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<boolean>}
*/
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;

17
src/routes/api/apiTest.js Normal file
View File

@ -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

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -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
;

34
src/routes/api/newQuiz.js Normal file
View File

@ -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
;

View File

@ -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",
// },
// ];

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);

378
src/routes/v1/api.route.js Normal file
View File

@ -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'
*/

291
src/routes/v1/auth.route.js Normal file
View File

@ -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
*/

View File

@ -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;

44
src/routes/v1/index.js Normal file
View File

@ -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;

252
src/routes/v1/user.route.js Normal file
View File

@ -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'
*/

View File

@ -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<User>}
*/
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<Object>}
*/
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,
};

View File

@ -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,
};

4
src/services/index.js Normal file
View File

@ -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');

View File

@ -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<Token>}
*/
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<Token>}
*/
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<Object>}
*/
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<string>}
*/
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<string>}
*/
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,
};

View File

@ -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<User>}
*/
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<QueryResult>}
*/
const queryUsers = async (filter, options) => {
const users = await User.paginate(filter, options);
return users;
};
/**
* Get user by id
* @param {ObjectId} id
* @returns {Promise<User>}
*/
const getUserById = async (id) => {
return User.findById(id);
};
/**
* Get user by email
* @param {string} email
* @returns {Promise<User>}
*/
const getUserByEmail = async (email) => {
return User.findOne({ email });
};
/**
* Update user by id
* @param {ObjectId} userId
* @param {Object} updateBody
* @returns {Promise<User>}
*/
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<User>}
*/
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,
};

14
src/utils/ApiError.js Normal file
View File

@ -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;

5
src/utils/catchAsync.js Normal file
View File

@ -0,0 +1,5 @@
const catchAsync = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch((err) => next(err));
};
module.exports = catchAsync;

17
src/utils/pick.js Normal file
View File

@ -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;

View File

@ -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,
};

View File

@ -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,
};

2
src/validations/index.js Normal file
View File

@ -0,0 +1,2 @@
module.exports.authValidation = require('./auth.validation');
module.exports.userValidation = require('./user.validation');

View File

@ -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,
};

14
tests/fixtures/token.fixture.js vendored Normal file
View File

@ -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,
};

46
tests/fixtures/user.fixture.js vendored Normal file
View File

@ -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,
};

View File

@ -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();
});
});

View File

@ -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;
});
});
});

View File

@ -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);
});
});
});

View File

@ -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;
});
});
});

View File

@ -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);
});
});
});

View File

@ -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');
});
});

View File

@ -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');
});
});
});

View File

@ -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;