├── .dockerignore ├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── node.js.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg ├── post-checkout ├── post-commit └── pre-commit ├── .lintstagedrc.json ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── TODO.md ├── bin └── createNodetsApp.cjs ├── commitlint.config.cjs ├── docker-compose.dev.yml ├── docker-compose.prod.yml ├── docker-compose.test.yml ├── docker-compose.yml ├── ecosystem.config.json ├── jest.config.cjs ├── package.json ├── packages └── components.yaml ├── src ├── app.ts ├── config │ ├── config.ts │ └── roles.ts ├── custom.d.ts ├── declaration.d.ts ├── index.ts ├── modules │ ├── auth │ │ ├── auth.controller.ts │ │ ├── auth.middleware.ts │ │ ├── auth.service.ts │ │ ├── auth.test.ts │ │ ├── auth.validation.ts │ │ ├── index.ts │ │ └── passport.ts │ ├── email │ │ ├── email.interfaces.ts │ │ ├── email.service.ts │ │ └── index.ts │ ├── errors │ │ ├── ApiError.ts │ │ ├── error.test.ts │ │ ├── error.ts │ │ └── index.ts │ ├── jest │ │ └── setupTestDB.ts │ ├── logger │ │ ├── index.ts │ │ ├── logger.ts │ │ └── morgan.ts │ ├── paginate │ │ ├── index.ts │ │ ├── paginate.test.ts │ │ ├── paginate.ts │ │ └── paginate.types.ts │ ├── swagger │ │ └── swagger.definition.ts │ ├── toJSON │ │ ├── index.ts │ │ ├── toJSON.test.ts │ │ └── toJSON.ts │ ├── token │ │ ├── index.ts │ │ ├── token.interfaces.ts │ │ ├── token.model.test.ts │ │ ├── token.model.ts │ │ ├── token.service.ts │ │ └── token.types.ts │ ├── user │ │ ├── index.ts │ │ ├── user.controller.ts │ │ ├── user.interfaces.ts │ │ ├── user.model.test.ts │ │ ├── user.model.ts │ │ ├── user.service.ts │ │ ├── user.test.ts │ │ └── user.validation.ts │ ├── utils │ │ ├── catchAsync.ts │ │ ├── index.ts │ │ ├── pick.ts │ │ └── rateLimiter.ts │ └── validate │ │ ├── custom.validation.ts │ │ ├── index.ts │ │ └── validate.middleware.ts └── routes │ └── v1 │ ├── auth.route.ts │ ├── index.ts │ ├── swagger.route.ts │ └── user.route.ts ├── tsconfig.json ├── tsconfig.tsbuildinfo └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .gitignore 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Port number 2 | PORT=3000 3 | 4 | # URL of the Mongo DB 5 | MONGODB_URL=mongodb://127.0.0.1:27017/node-ts-boilerplate 6 | 7 | # JWT 8 | # JWT secret key 9 | JWT_SECRET=thisisasamplesecret 10 | # Number of minutes after which an access token expires 11 | JWT_ACCESS_EXPIRATION_MINUTES=30 12 | # Number of days after which a refresh token expires 13 | JWT_REFRESH_EXPIRATION_DAYS=30 14 | # Number of minutes after which a reset password token expires 15 | JWT_RESET_PASSWORD_EXPIRATION_MINUTES=10 16 | # Number of minutes after which a verify email token expires 17 | JWT_VERIFY_EMAIL_EXPIRATION_MINUTES=10 18 | 19 | # SMTP configuration options for the email service 20 | # For testing, you can use a fake SMTP service like Ethereal: https://ethereal.email/create 21 | SMTP_HOST=email-server 22 | SMTP_PORT=587 23 | SMTP_USERNAME=email-server-username 24 | SMTP_PASSWORD=email-server-password 25 | EMAIL_FROM=support@yourapp.com 26 | 27 | # Cookie configs 28 | COOKIE_SECRET=thisisasamplesecret 29 | 30 | # URL of client application 31 | CLIENT_URL=http://localhost:5000 32 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bin 3 | data 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "jest": true 5 | }, 6 | "extends": ["airbnb-base", "plugin:jest/recommended", "plugin:security/recommended", "plugin:prettier/recommended"], 7 | "plugins": ["jest", "security", "prettier"], 8 | "parserOptions": { 9 | "ecmaVersion": 2018 10 | }, 11 | "rules": { 12 | "no-console": "error", 13 | "func-names": "off", 14 | "no-underscore-dangle": "off", 15 | "consistent-return": "off", 16 | "jest/expect-expect": "off", 17 | "security/detect-object-injection": "off" 18 | }, 19 | "overrides": [ 20 | { 21 | "files":["src/**/*.ts"], 22 | "extends": ["airbnb-base", "airbnb-typescript/base", "plugin:prettier/recommended"], 23 | "plugins": ["@typescript-eslint", "prettier"], 24 | "parser": "@typescript-eslint/parser", 25 | "parserOptions": { 26 | "ecmaVersion": 2018, 27 | "project": ["./tsconfig.json"] 28 | }, 29 | "rules": { 30 | "no-console": "error", 31 | "func-names": "off", 32 | "no-underscore-dangle": "off", 33 | "consistent-return": "off" 34 | } 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Convert text file line endings to lf 2 | * text eol=lf 3 | *.js text 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | 2 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 3 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 4 | 5 | name: Node.js CI 6 | 7 | on: 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | 13 | env: 14 | PORT: 3000 15 | MONGODB_URL: mongodb://127.0.0.1:27017/node-ts-boilerplate 16 | JWT_SECRET: thisisasamplesecret 17 | JWT_ACCESS_EXPIRATION_MINUTES: 30 18 | JWT_REFRESH_EXPIRATION_DAYS: 30 19 | JWT_RESET_PASSWORD_EXPIRATION_MINUTES: 10 20 | JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: 10 21 | CLIENT_URL: http://localhost:3000 22 | 23 | jobs: 24 | 25 | test: 26 | 27 | runs-on: ubuntu-latest 28 | 29 | strategy: 30 | matrix: 31 | node-version: [14.x] 32 | mongodb-version: [4.0] 33 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 34 | 35 | steps: 36 | - uses: actions/checkout@v2 37 | - name: Use Node.js ${{ matrix.node-version }} 38 | uses: actions/setup-node@v1 39 | with: 40 | node-version: ${{ matrix.node-version }} 41 | - name: Start MongoDB 42 | uses: supercharge/mongodb-github-action@1.3.0 43 | with: 44 | mongodb-version: ${{ matrix.mongodb-version }} 45 | - run: yarn 46 | - name: Run linting tests 47 | run: yarn lint 48 | - name: Run unit tests 49 | run: yarn test 50 | 51 | coverage: 52 | 53 | runs-on: ubuntu-latest 54 | 55 | needs: test 56 | 57 | strategy: 58 | matrix: 59 | node-version: [14.x] 60 | mongodb-version: [4.0] 61 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 62 | 63 | steps: 64 | - uses: actions/checkout@v2 65 | - name: Use Node.js ${{ matrix.node-version }} 66 | uses: actions/setup-node@v1 67 | with: 68 | node-version: ${{ matrix.node-version }} 69 | - name: Start MongoDB 70 | uses: supercharge/mongodb-github-action@1.3.0 71 | with: 72 | mongodb-version: ${{ matrix.mongodb-version }} 73 | - run: yarn 74 | - name: Generate coverage 75 | run: yarn coverage:coveralls 76 | - name: Upload coverage to Codecov 77 | uses: codecov/codecov-action@v2 78 | with: 79 | fail_ci_if_error: true 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # yarn error logs 5 | yarn-error.log 6 | 7 | # Environment varibales 8 | .env* 9 | !.env*.example 10 | 11 | # Code coverage 12 | coverage 13 | 14 | # build folder 15 | dist 16 | 17 | # docker volume 18 | data -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/post-checkout: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn install 5 | -------------------------------------------------------------------------------- /.husky/post-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | git status 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.js": "eslint" 3 | } 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.20.1 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | data 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 125 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [3.0.11](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v3.0.10...v3.0.11) (2024-02-14) 6 | 7 | ### [3.0.10](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v3.0.9...v3.0.10) (2023-07-22) 8 | 9 | ### [3.0.9](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v3.0.8...v3.0.9) (2023-05-04) 10 | 11 | ### [3.0.8](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v3.0.7...v3.0.8) (2023-04-13) 12 | 13 | ### [3.0.7](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v3.0.6...v3.0.7) (2023-04-10) 14 | 15 | ### [3.0.6](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v3.0.5...v3.0.6) (2023-03-22) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * update minimum node version to match mongoose ([#40](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/issues/40)) ([272b29e](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/272b29ef7da5a0df9d97223bdf4c97b98b49a65a)) 21 | 22 | ### [3.0.5](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v3.0.4...v3.0.5) (2022-12-23) 23 | 24 | ### [3.0.4](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v3.0.3...v3.0.4) (2022-12-16) 25 | 26 | ### [3.0.3](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v3.0.2...v3.0.3) (2022-11-26) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * **package.json:** remove auto npm publish ([1c23583](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/1c235836497095f01362a89d44a7b1ae51ae28cd)) 32 | 33 | ### [3.0.2](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v3.0.1...v3.0.2) (2022-11-26) 34 | 35 | ### [3.0.1](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v3.0.0...v3.0.1) (2022-11-26) 36 | 37 | 38 | ### Bug Fixes 39 | 40 | * **package.json:** add range for Node.js versions ([c0ff5bb](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/c0ff5bbac7fd04d9cbbe8287143386ab7ba5ef8b)), closes [#32](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/issues/32) 41 | * prettier for ts files ([36b66c7](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/36b66c772eb883f6c39687811a52243dbc8be26b)) 42 | 43 | ## [3.0.0](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v2.4.0...v3.0.0) (2022-10-29) 44 | 45 | 46 | ### ⚠ BREAKING CHANGES 47 | 48 | * yarn start does not build before starting. Use yarn start:build if you want to 49 | build before starting. FRONT_URL has been replaced with CLIENT_URL as it is more descriptive 50 | 51 | ### Bug Fixes 52 | 53 | * fix docker config ([f762ea8](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/f762ea8f44bd93f97cd85be76848b280c4155102)), closes [#28](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/issues/28) 54 | 55 | ## [2.4.0](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v2.3.0...v2.4.0) (2022-10-17) 56 | 57 | 58 | ### Features 59 | 60 | * add a security measure to forgot-password ([dad0e99](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/dad0e9990c4d4882d765c383153aeb28bbd5b9c6)) 61 | * add front-end url as an env variable ([9dd8932](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/9dd8932f6b8d7cad082df9030dc6db0c070a24f8)) 62 | 63 | 64 | ### Bug Fixes 65 | 66 | * include FRONT_URL in the workflow ([8674603](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/8674603c6d086ff06461334ca9871818d3d4288b)) 67 | 68 | ## [2.3.0](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v2.2.1...v2.3.0) (2022-10-17) 69 | 70 | 71 | ### Features 72 | 73 | * add npm quick start ([a19bda6](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/a19bda6f989e2c71eef8eea8b06ab78a6b62c2fe)) 74 | 75 | ### [2.2.1](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v2.2.0...v2.2.1) (2022-10-17) 76 | 77 | ## [2.2.0](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v2.1.3...v2.2.0) (2022-09-23) 78 | 79 | 80 | ### Features 81 | 82 | * upgrade nodemon ([8e223e9](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/8e223e9f8a857adf9f9f0b280cdac4af1a30d9a3)) 83 | 84 | ### [2.1.3](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v2.1.2...v2.1.3) (2022-09-21) 85 | 86 | ### [2.1.2](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v2.1.1...v2.1.2) (2022-05-21) 87 | 88 | 89 | ### Bug Fixes 90 | 91 | * **package.json:** update commitizen to secure version ([12c263c](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/12c263c37f2809494dbb33054083be13c6c85bca)) 92 | * update yarn.lock ([66d6e5d](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/66d6e5d9bb06350e506672a2368ea92c1fbce4eb)) 93 | 94 | ### [2.1.1](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v2.1.0...v2.1.1) (2022-05-20) 95 | 96 | 97 | ### Bug Fixes 98 | 99 | * **package.json:** fix minimist resolution ([416f6eb](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/416f6eb80d60fbcceb530adbaa2b8a51a92eb072)) 100 | 101 | ## [2.1.0](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v2.0.0...v2.1.0) (2022-05-19) 102 | 103 | 104 | ### Features 105 | 106 | * move faker to devdependencies ([e09711a](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/e09711aecd2e01b2dee208bc9c6052ce59514d39)) 107 | 108 | 109 | ### Bug Fixes 110 | 111 | * **package.json:** remove linting from start script ([ffa8c7e](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/ffa8c7e261a488cbd4e23113b0dade258869ca0c)) 112 | 113 | ## [2.0.0](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v1.1.0...v2.0.0) (2022-04-26) 114 | 115 | 116 | ### ⚠ BREAKING CHANGES 117 | 118 | * **components.yaml:** swagger routes have to use the new components.yaml path i.e. 119 | packages/components.yaml 120 | * auth/refresh-token api will return user alongside access token and refresh token 121 | 122 | ### Features 123 | 124 | * add hot reloading ([8a11c8a](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/8a11c8a6c529f542b7af2c20978605dd3214162a)), closes [#12](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/issues/12) 125 | 126 | 127 | ### Bug Fixes 128 | 129 | * **components.yaml:** move components.yaml out of src ([3decabe](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/3decabe03934a8f0ecebc1c81b50b5f12ec73ab5)) 130 | * ts build removeComments ([e94887f](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/e94887f7b5fd4694093bae89219dc42d29abba55)) 131 | 132 | 133 | * change refresh token response ([948993d](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/948993df0297ce3a75390a305563b61a5345a19d)) 134 | 135 | ## [1.1.0](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v1.0.0...v1.1.0) (2022-04-19) 136 | 137 | 138 | ### Features 139 | 140 | * add coverage ([ea05317](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/ea053179e467644ef5e55523f38d1ac1f16729c7)) 141 | * add paginate tests ([c9f83bf](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/c9f83bfeac72d47ab56059c74c98d2d3b998e2fb)) 142 | * add publish action ([9652d49](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/9652d49fb1dc7e70b71ebd58ccac1e07d3155e72)) 143 | * add toJSON tests ([86a9fbe](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/86a9fbe42dd32443e3b038cb8a94a07ec7506f16)) 144 | * add token model tests ([537e553](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/537e5533bb88d965ba1654e7ba9bd4af9b15e1f8)) 145 | 146 | 147 | ### Bug Fixes 148 | 149 | * cannot define both `uses` and `steps` ([4c78224](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/4c782247afdc8b292788060b9aa081f1721cb60e)) 150 | * ci ([1e22672](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/1e2267260ec170a5adc80c6d06cc957025d2e592)) 151 | * ci strategy ([ffbe250](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/ffbe25041dfe50e4ec25d844a62d4cc8d3a0c67d)) 152 | * ci tests ([45da4d3](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/45da4d3a4b8819720fdbd551086daea0119f1ce7)) 153 | * code duplication ([3526605](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/3526605d1b1ee663741e53472f16e17e71936a2e)) 154 | * continuous integration errors ([c673d37](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/c673d378ee86ca903ec821e86539864cd737f32e)) 155 | * coverage ([eb8a75d](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/eb8a75d23c99165ba675e1adb70b5bf29611236d)) 156 | * coverage report ([5be0921](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/5be0921dfc10c347fbfb7df2a21e723e10d741e1)) 157 | * coveralls ([9748c19](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/9748c19e7eae2a5fe88f90d6f4dccc125a5c349d)) 158 | * every step must have uses or run ([9276a0c](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/9276a0c21b86ef8c3667ff4426e05ba842080959)) 159 | * tests ([3e16f9b](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/3e16f9b552dc13eeb6524687d3e42f1bc6b9d43e)) 160 | * uses in node.js.yml ([1b3e65b](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/1b3e65b4d1d4c07ca5904cc37c072d4582aea834)) 161 | 162 | ## [1.0.0](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v0.0.4...v1.0.0) (2022-04-08) 163 | 164 | 165 | ### Features 166 | 167 | * add commitlint ([9f98cda](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/9f98cdaadacc8fa6387ecf4d4232848915ad5a79)) 168 | * add status badge ([794b205](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/794b2058f55ce633bbf075920880a4438dbc9fcd)) 169 | 170 | 171 | ### Bug Fixes 172 | 173 | * add env variables to node.js.yml ([7bd7c28](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/7bd7c28826442f1893f3cf4d341a56505e875192)) 174 | 175 | ### [0.0.4](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v0.0.3...v0.0.4) (2022-04-08) 176 | 177 | 178 | ### Bug Fixes 179 | 180 | * husky permissions ([96fee26](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/96fee26d21b543b865c306c79a99729019d4bcf0)) 181 | 182 | ### [0.0.3](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/compare/v0.0.2...v0.0.3) (2022-04-08) 183 | 184 | ### 0.0.2 (2022-04-08) 185 | 186 | 187 | ### Features 188 | 189 | * add bearerAuth ([905261c](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/905261c9ed7750ed1ff6c4ebf029bc6303f2c345)) 190 | * add JS build ([66122f9](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/66122f903301ca69de02a149566ce69b9b54f77b)) 191 | * add modules folder ([09f0b1a](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/09f0b1a1508658218f9be02ae6d31323d458d932)) 192 | * add plugins ([857a465](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/857a465bff68cea364feaf3d639b160de754d670)) 193 | * add projection ([488405a](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/488405a4001a12d1fa7c0fe23a3d1cf0b6c52ab5)) 194 | * add routes + cookieAuth ([58cecb3](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/58cecb30460b2bfcc5b63df347232cb448fc39e4)) 195 | * add standard version ([5b1eb4f](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/5b1eb4f405c8c5ffd68e0aa4927aa80848e0c4fd)) 196 | * add swagger config ([dd226d5](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/dd226d591620b0bdf45b194bc7949b3d4c73bef5)) 197 | * add token + user models ([02ffcf8](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/02ffcf859bd5704b89807cc17f0609216d6a2030)) 198 | * add token auth ([5a3597c](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/5a3597c7d68cde0c85c02b827e893ef1db757a18)) 199 | * add user + auth routes ([29793dc](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/29793dc0296f74afb8bd20916236269e18440099)) 200 | * add user +auth controllers ([cb09ae3](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/cb09ae3aecff8266497e26233a844764e2cae082)) 201 | * add user and token services ([a92dd01](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/a92dd01f259a73405c30e2240c5533ba1c57cc16)) 202 | * add user tests ([1a6ec87](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/1a6ec87e2273f57fcdb349d6abc7fdf83301a7d6)) 203 | * add user, auth, token services ([26736f7](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/26736f73642ca0b1264a1fd02bec51a49bbe3dd6)) 204 | * add utils ([ef73bd4](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/ef73bd43f714087d083fc54fc9de6b7d9e331bc0)) 205 | * add validations ([7547099](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/75470995318ac2106fad45d3fe0ae381e9d994a7)) 206 | * changed folder structure ([cf3b47d](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/cf3b47d414773e978f3cab54fed3a4acf828fa9e)) 207 | * initial build ([eea36d4](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/eea36d45a6351329a00b25cdba10d5be22910c54)) 208 | 209 | 210 | ### Bug Fixes 211 | 212 | * .env + config import ([d0afb24](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/d0afb242c002058419e49121aaad0451608a7e9b)) 213 | * controller + validator naming ([50e6ffa](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/50e6ffa8f23814c3ec2dabbe43f6ccf386fe117e)) 214 | * error handler tests ([50ed723](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/50ed723b39cee9b4e96b2f6cd92a67417907f95c)) 215 | * error handler text/html output ([1c16df1](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/1c16df1488d95dbab9123c68479780483a38c4bd)) 216 | * folder structure ([ffd27a4](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/ffd27a4e5fc800a8fc8249fea88e194ef9b13c8c)) 217 | * swagger api route ([735cac4](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/735cac4dfd2bbbb837dda595a07852b1e1a729f5)) 218 | * tests ([91b6aa9](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/91b6aa95346cccbc15633c131ed071417ea6b808)) 219 | * verify-email test ([bf40918](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/commit/bf40918f782b63bd983c17db5ff6a2302df0180d)) 220 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at hagopj13@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First off, thank you so much for taking the time to contribute. All contributions are more than welcome! 4 | 5 | ## How can I contribute? 6 | 7 | If you have an awesome new feature that you want to implement or you found a bug that you would like to fix, here are some instructions to guide you through the process: 8 | 9 | - **Create an issue** to explain and discuss the details 10 | - **Fork the repo** 11 | - **Clone the repo** and set it up (check out the [manual installation](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate.git#manual-installation) section in README.md) 12 | - **Implement** the necessary changes 13 | - **Create tests** to keep the code coverage high 14 | - **Send a pull request** 15 | 16 | ## Guidelines 17 | 18 | ### Git commit messages 19 | 20 | - Limit the subject line to 72 characters 21 | - Capitalize the first letter of the subject line 22 | - Use the present tense ("Add feature" instead of "Added feature") 23 | - Separate the subject from the body with a blank line 24 | - Reference issues and pull requests in the body 25 | 26 | ### Coding style guide 27 | 28 | We are using ESLint to ensure a consistent code style in the project, based on [Airbnb's JS style guide](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb-base). 29 | 30 | Some other ESLint plugins are also being used, such as the [Prettier](https://github.com/prettier/eslint-plugin-prettier) and [Jest](https://github.com/jest-community/eslint-plugin-jest) plugins. 31 | 32 | Please make sure that the code you are pushing conforms to the style guides mentioned above. 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # development stage 2 | FROM node:14-alpine as base 3 | 4 | WORKDIR /usr/src/app 5 | 6 | COPY package.json yarn.lock tsconfig.json ecosystem.config.json ./ 7 | 8 | COPY ./src ./src 9 | 10 | RUN ls -a 11 | 12 | RUN yarn install --pure-lockfile && yarn compile 13 | 14 | # production stage 15 | 16 | FROM base as production 17 | 18 | WORKDIR /usr/prod/app 19 | 20 | ENV NODE_ENV=production 21 | 22 | COPY package.json yarn.lock ecosystem.config.json ./ 23 | 24 | RUN yarn install --production --pure-lockfile 25 | 26 | COPY --from=base /usr/src/app/dist ./dist 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Linus Saisi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RESTful API Node Typescript Server Boilerplate 2 | 3 | [![Node.js CI](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/actions/workflows/node.js.yml/badge.svg)](https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/actions/workflows/node.js.yml) 4 | [![codecov](https://codecov.io/gh/saisilinus/node-express-mongoose-typescript-boilerplate/branch/master/graph/badge.svg?token=UYJAL9KTMD)](https://codecov.io/gh/saisilinus/node-express-mongoose-typescript-boilerplate) 5 | 6 | By running a single command, you will get a production-ready Node.js TypeScript app installed and fully configured on your machine. The app comes with many built-in features, such as authentication using JWT, request validation, unit and integration tests, continuous integration, docker support, API documentation, pagination, etc. For more details, check the features list below. 7 | 8 | ## Not Compatible with Node.js v19 9 | 10 | Node.js has deprecated the `--es-module-specifier-resolution=node` flag, used in this app, in the release of [Node.js v19](https://nodejs.org/en/blog/announcements/v19-release-announce/#custom-esm-resolution-adjustments) in favor of [custom loaders](https://github.com/nodejs/loaders-test/tree/main/commonjs-extension-resolution-loader). You can check out the PR [here](https://github.com/nodejs/node/pull/44859). 11 | 12 | As a result, this app is not compatible with Node.js >=19. You can add support to your app using this [loader](https://github.com/nodejs/loaders-test/tree/main/commonjs-extension-resolution-loader) 13 | 14 | ## Quick Start 15 | 16 | To create a project, simply run: 17 | 18 | ```bash 19 | npx create-nodejs-ts-app 20 | ``` 21 | 22 | Or 23 | 24 | ```bash 25 | npm init nodejs-ts-app 26 | ``` 27 | 28 | ## Manual Installation 29 | 30 | Clone the repo: 31 | 32 | ```bash 33 | git clone --depth 1 https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate.git 34 | cd node-express-mongoose-typescript-boilerplate 35 | ``` 36 | 37 | Install the dependencies: 38 | 39 | ```bash 40 | yarn install 41 | ``` 42 | 43 | Set the environment variables: 44 | 45 | ```bash 46 | cp .env.example .env 47 | 48 | # open .env and modify the environment variables (if needed) 49 | ``` 50 | 51 | ## Table of Contents 52 | 53 | - [Features](#features) 54 | - [Commands](#commands) 55 | - [Making Changes](#making-changes) 56 | - [Environment Variables](#environment-variables) 57 | - [Project Structure](#project-structure) 58 | - [API Documentation](#api-documentation) 59 | - [Error Handling](#error-handling) 60 | - [Validation](#validation) 61 | - [Authentication](#authentication) 62 | - [Authorization](#authorization) 63 | - [Logging](#logging) 64 | - [Custom Mongoose Plugins](#custom-mongoose-plugins) 65 | - [To JSON Plugin](#tojson) 66 | - [Paginate Plugin](#paginate) 67 | - [Linting](#linting) 68 | - [Contributing](#contributing) 69 | - [Inspirations](#inspirations) 70 | - [License](#license) 71 | 72 | ## Features 73 | 74 | - **ES9**: latest ECMAScript features 75 | - **Static Typing**: [TypeScript](https://www.typescriptlang.org/) static typing using typescript 76 | - **Hot Reloading**: [Concurrently](https://github.com/open-cli-tools/concurrently) Hot realoding with concurrently 77 | - **NoSQL database**: [MongoDB](https://www.mongodb.com) object data modeling using [Mongoose](https://mongoosejs.com) 78 | - **Authentication and authorization**: using [passport](http://www.passportjs.org) 79 | - **Validation**: request data validation using [Joi](https://github.com/hapijs/joi) 80 | - **Logging**: using [winston](https://github.com/winstonjs/winston) and [morgan](https://github.com/expressjs/morgan) 81 | - **Testing**: unit and integration tests using [Jest](https://jestjs.io) 82 | - **Error handling**: centralized error handling mechanism 83 | - **API documentation**: with [swagger-jsdoc](https://github.com/Surnet/swagger-jsdoc) and [swagger-ui-express](https://github.com/scottie1984/swagger-ui-express) 84 | - **Process management**: advanced production process management using [PM2](https://pm2.keymetrics.io) 85 | - **Dependency management**: with [Yarn](https://yarnpkg.com) 86 | - **Environment variables**: using [dotenv](https://github.com/motdotla/dotenv) and [cross-env](https://github.com/kentcdodds/cross-env#readme) 87 | - **Security**: set security HTTP headers using [helmet](https://helmetjs.github.io) 88 | - **Santizing**: sanitize request data against xss and query injection 89 | - **CORS**: Cross-Origin Resource-Sharing enabled using [cors](https://github.com/expressjs/cors) 90 | - **Compression**: gzip compression with [compression](https://github.com/expressjs/compression) 91 | - **CI**: continuous integration with [GitHub CI](https://travis-ci.org) 92 | - **Docker support** 93 | - **Code coverage**: using [codecov](https://about.codecov.io/) 94 | - **Code quality**: with [Codacy](https://www.codacy.com) 95 | - **Git hooks**: with [husky](https://github.com/typicode/husky) and [lint-staged](https://github.com/okonet/lint-staged) 96 | - **Linting**: with [ESLint](https://eslint.org) and [Prettier](https://prettier.io) 97 | - **Editor config**: consistent editor configuration using [EditorConfig](https://editorconfig.org) 98 | - **Changelog Generation**: with [Standard Version](https://github.com/conventional-changelog/standard-version) 99 | - **Structured Commit Messages**: with [Commitizen](https://github.com/commitizen/cz-cli) 100 | - **Commit Linting**: with [CommitLint](https://github.com/conventional-changelog/commitlint) 101 | 102 | ## Commands 103 | 104 | Running locally: 105 | 106 | ```bash 107 | yarn dev 108 | ``` 109 | 110 | Running in production: 111 | 112 | ```bash 113 | yarn start 114 | ``` 115 | 116 | Compiling to JS from TS 117 | 118 | ```bash 119 | yarn compile 120 | ``` 121 | 122 | Compiling to JS from TS in watch mode 123 | 124 | ```bash 125 | yarn compile:watch 126 | ``` 127 | 128 | Commiting changes 129 | 130 | ```bash 131 | yarn commit 132 | ``` 133 | 134 | Testing: 135 | 136 | ```bash 137 | # run all tests 138 | yarn test 139 | 140 | # run TypeScript tests 141 | yarn test:ts 142 | 143 | # run JS tests 144 | yarn test:js 145 | 146 | # run all tests in watch mode 147 | yarn test:watch 148 | 149 | # run test coverage 150 | yarn coverage 151 | ``` 152 | 153 | Docker: 154 | 155 | ```bash 156 | # run docker container in development mode 157 | yarn docker:dev 158 | 159 | # run docker container in production mode 160 | yarn docker:prod 161 | 162 | # run all tests in a docker container 163 | yarn docker:test 164 | ``` 165 | 166 | Linting: 167 | 168 | ```bash 169 | # run ESLint 170 | yarn lint 171 | 172 | # fix ESLint errors 173 | yarn lint:fix 174 | 175 | # run prettier 176 | yarn prettier 177 | 178 | # fix prettier errors 179 | yarn prettier:fix 180 | ``` 181 | 182 | ## Making Changes 183 | 184 | Run `yarn dev` so you can compile Typescript(.ts) files in watch mode 185 | 186 | ```bash 187 | yarn dev 188 | ``` 189 | 190 | Add your changes to TypeScript(.ts) files which are in the src folder. The files will be automatically compiled to JS if you are in watch mode. 191 | 192 | Add tests for the new feature 193 | 194 | Run `yarn test:ts` to make sure all Typescript tests pass. 195 | 196 | ```bash 197 | yarn test:ts 198 | ``` 199 | 200 | ## Environment Variables 201 | 202 | The environment variables can be found and modified in the `.env` file. They come with these default values: 203 | 204 | ```bash 205 | # Port number 206 | PORT=3000 207 | 208 | # URL of the Mongo DB 209 | MONGODB_URL=mongodb://127.0.0.1:27017/Park254_Backend 210 | 211 | # JWT 212 | # JWT secret key 213 | JWT_SECRET=thisisasamplesecret 214 | # Number of minutes after which an access token expires 215 | JWT_ACCESS_EXPIRATION_MINUTES=30 216 | # Number of days after which a refresh token expires 217 | JWT_REFRESH_EXPIRATION_DAYS=30 218 | 219 | # SMTP configuration options for the email service 220 | # For testing, you can use a fake SMTP service like Ethereal: https://ethereal.email/create 221 | SMTP_HOST=email-server 222 | SMTP_PORT=587 223 | SMTP_USERNAME=email-server-username 224 | SMTP_PASSWORD=email-server-password 225 | EMAIL_FROM=support@yourapp.com 226 | 227 | # URL of client application 228 | CLIENT_URL=http://localhost:5000 229 | ``` 230 | 231 | ## Project Structure 232 | 233 | ``` 234 | . 235 | ├── src # Source files 236 | │ ├── app.ts # Express App 237 | │ ├── config # Environment variables and other configurations 238 | │ ├── custom.d.ts # File for extending types from node modules 239 | │ ├── declaration.d.ts # File for declaring modules without types 240 | │ ├── index.ts # App entry file 241 | │ ├── modules # Modules such as models, controllers, services 242 | │ └── routes # Routes 243 | ├── TODO.md # TODO List 244 | ├── package.json 245 | └── README.md 246 | ``` 247 | 248 | ## API Documentation 249 | 250 | 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. 251 | 252 | ### API Endpoints 253 | 254 | List of available routes: 255 | 256 | **Auth routes**:\ 257 | `POST /v1/auth/register` - register\ 258 | `POST /v1/auth/login` - login\ 259 | `POST /v1/auth/refresh-tokens` - refresh auth tokens\ 260 | `POST /v1/auth/forgot-password` - send reset password email\ 261 | `POST /v1/auth/reset-password` - reset password 262 | 263 | **User routes**:\ 264 | `POST /v1/users` - create a user\ 265 | `GET /v1/users` - get all users\ 266 | `GET /v1/users/:userId` - get user\ 267 | `PATCH /v1/users/:userId` - update user\ 268 | `DELETE /v1/users/:userId` - delete user 269 | 270 | ## Error Handling 271 | 272 | The app has a centralized error handling mechanism. 273 | 274 | 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. 275 | 276 | ```javascript 277 | const catchAsync = require('../utils/catchAsync'); 278 | 279 | const controller = catchAsync(async (req, res) => { 280 | // this error will be forwarded to the error handling middleware 281 | throw new Error('Something wrong happened'); 282 | }); 283 | ``` 284 | 285 | The error handling middleware sends an error response, which has the following format: 286 | 287 | ```json 288 | { 289 | "code": 404, 290 | "message": "Not found" 291 | } 292 | ``` 293 | 294 | When running in development mode, the error response also contains the error stack. 295 | 296 | 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). 297 | 298 | 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: 299 | 300 | ```javascript 301 | const httpStatus = require('http-status'); 302 | const ApiError = require('../utils/ApiError'); 303 | const User = require('../models/User'); 304 | 305 | const getUser = async (userId) => { 306 | const user = await User.findById(userId); 307 | if (!user) { 308 | throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); 309 | } 310 | }; 311 | ``` 312 | 313 | ## Validation 314 | 315 | 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. 316 | 317 | 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. 318 | 319 | ```javascript 320 | const express = require('express'); 321 | const validate = require('../../middlewares/validate'); 322 | const userValidation = require('../../validations/user.validation'); 323 | const userController = require('../../controllers/user.controller'); 324 | 325 | const router = express.Router(); 326 | 327 | router.post('/users', validate(userValidation.createUser), userController.createUser); 328 | ``` 329 | 330 | ## Authentication 331 | 332 | To require authentication for certain routes, you can use the `auth` middleware. 333 | 334 | ```javascript 335 | const express = require('express'); 336 | const auth = require('../../middlewares/auth'); 337 | const userController = require('../../controllers/user.controller'); 338 | 339 | const router = express.Router(); 340 | 341 | router.post('/users', auth(), userController.createUser); 342 | ``` 343 | 344 | 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. 345 | 346 | **Generating Access Tokens**: 347 | 348 | 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). 349 | 350 | 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. 351 | 352 | **Refreshing Access Tokens**: 353 | 354 | 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. 355 | 356 | 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. 357 | 358 | ## Authorization 359 | 360 | The `auth` middleware can also be used to require certain rights/permissions to access a route. 361 | 362 | ```javascript 363 | const express = require('express'); 364 | const auth = require('../../middlewares/auth'); 365 | const userController = require('../../controllers/user.controller'); 366 | 367 | const router = express.Router(); 368 | 369 | router.post('/users', auth('manageUsers'), userController.createUser); 370 | ``` 371 | 372 | In the example above, an authenticated user can access this route only if that user has the `manageUsers` permission. 373 | 374 | The permissions are role-based. You can view the permissions/rights of each role in the `src/config/roles.js` file. 375 | 376 | If the user making the request does not have the required permissions to access this route, a Forbidden (403) error is thrown. 377 | 378 | ## Logging 379 | 380 | Import the logger from `src/config/logger.js`. It is using the [Winston](https://github.com/winstonjs/winston) logging library. 381 | 382 | Logging should be done according to the following severity levels (ascending order from most important to least important): 383 | 384 | ```javascript 385 | const logger = require('/config/logger'); 386 | 387 | logger.error('message'); // level 0 388 | logger.warn('message'); // level 1 389 | logger.info('message'); // level 2 390 | logger.http('message'); // level 3 391 | logger.verbose('message'); // level 4 392 | logger.debug('message'); // level 5 393 | ``` 394 | 395 | In development mode, log messages of all severity levels will be printed to the console. 396 | 397 | In production mode, only `info`, `warn`, and `error` logs will be printed to the console.\ 398 | It is up to the server (or process manager) to actually read them from the console and store them in log files.\ 399 | This app uses pm2 in production mode, which is already configured to store the logs in log files. 400 | 401 | Note: API request information (request url, response code, timestamp, etc.) are also automatically logged (using [morgan](https://github.com/expressjs/morgan)). 402 | 403 | ## Custom Mongoose Plugins 404 | 405 | 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`. 406 | 407 | ```javascript 408 | const mongoose = require('mongoose'); 409 | const { toJSON, paginate } = require('./plugins'); 410 | 411 | const userSchema = mongoose.Schema( 412 | { 413 | /* schema definition here */ 414 | }, 415 | { timestamps: true } 416 | ); 417 | 418 | userSchema.plugin(toJSON); 419 | userSchema.plugin(paginate); 420 | 421 | const User = mongoose.model('User', userSchema); 422 | ``` 423 | 424 | ### toJSON 425 | 426 | The toJSON plugin applies the following changes in the toJSON transform call: 427 | 428 | - removes \_\_v, createdAt, updatedAt, and any schema path that has private: true 429 | - replaces \_id with id 430 | 431 | ### paginate 432 | 433 | The paginate plugin adds the `paginate` static method to the mongoose schema. 434 | 435 | Adding this plugin to the `User` model schema will allow you to do the following: 436 | 437 | ```javascript 438 | const queryUsers = async (filter, options) => { 439 | const users = await User.paginate(filter, options); 440 | return users; 441 | }; 442 | ``` 443 | 444 | The `filter` param is a regular mongo filter. 445 | 446 | The `options` param can have the following (optional) fields: 447 | 448 | ```javascript 449 | const options = { 450 | sortBy: 'name:desc', // sort order 451 | limit: 5, // maximum results per page 452 | page: 2, // page number 453 | projectBy: 'name:hide, role:hide', // fields to hide or include in the results 454 | }; 455 | ``` 456 | 457 | The `projectBy` option can include multiple criteria (separated by a comma) but cannot include and exclude fields at the same time. Check out the following examples: 458 | 459 | - [x] `name:hide, role:hide` should work 460 | - [x] `name:include, role:include` should work 461 | - [ ] `name:include, role:hide` will not work 462 | 463 | The plugin also supports sorting by multiple criteria (separated by a comma): `sortBy: name:desc,role:asc` 464 | 465 | The `paginate` method returns a Promise, which fulfills with an object having the following properties: 466 | 467 | ```json 468 | { 469 | "results": [], 470 | "page": 2, 471 | "limit": 5, 472 | "totalPages": 10, 473 | "totalResults": 48 474 | } 475 | ``` 476 | 477 | ## Linting 478 | 479 | Linting is done using [ESLint](https://eslint.org/) and [Prettier](https://prettier.io). 480 | 481 | 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. 482 | 483 | To modify the ESLint configuration, update the `.eslintrc.json` file. To modify the Prettier configuration, update the `.prettierrc.json` file. 484 | 485 | To prevent a certain file or directory from being linted, add it to `.eslintignore` and `.prettierignore`. 486 | 487 | To maintain a consistent coding style across different IDEs, the project contains `.editorconfig` 488 | 489 | ## Contributing 490 | 491 | Contributions are more than welcome! Please check out the [contributing guide](CONTRIBUTING.md). 492 | 493 | ## Inspirations 494 | 495 | - [hagopj13/node-express-boilerplate](https://github.com/hagopj13/node-express-boilerplate.git) 496 | 497 | ## License 498 | 499 | [MIT](LICENSE) 500 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ### Testing Tasks 2 | 3 | - [ ] Improve coverage 4 | - [x] Add paginate tests 5 | - [x] Add toJSON tests 6 | 7 | ### Other Tasks 8 | 9 | - [x] Have faker.js as a dev dependency 10 | - [ ] Add Cookie Support 11 | -------------------------------------------------------------------------------- /bin/createNodetsApp.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const util = require('util'); 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const { execSync } = require('child_process'); 6 | 7 | // Utility functions 8 | const exec = util.promisify(require('child_process').exec); 9 | async function runCmd(command) { 10 | try { 11 | const { stdout, stderr } = await exec(command); 12 | console.log(stdout); 13 | console.log(stderr); 14 | } catch { 15 | (error) => { 16 | console.log(error); 17 | }; 18 | } 19 | } 20 | 21 | async function hasYarn() { 22 | try { 23 | await execSync('yarnpkg --version', { stdio: 'ignore' }); 24 | return true; 25 | } catch { 26 | return false; 27 | } 28 | } 29 | 30 | // Validate arguments 31 | if (process.argv.length < 3) { 32 | console.log('Please specify the target project directory.'); 33 | console.log('For example:'); 34 | console.log(' npx create-nodejs-app my-app'); 35 | console.log(' OR'); 36 | console.log(' npm init nodejs-app my-app'); 37 | process.exit(1); 38 | } 39 | 40 | // Define constants 41 | const ownPath = process.cwd(); 42 | const folderName = process.argv[2]; 43 | const appPath = path.join(ownPath, folderName); 44 | const repo = 'https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate.git'; 45 | 46 | // Check if directory already exists 47 | try { 48 | fs.mkdirSync(appPath); 49 | } catch (err) { 50 | if (err.code === 'EEXIST') { 51 | console.log('Directory already exists. Please choose another name for the project.'); 52 | } else { 53 | console.log(err); 54 | } 55 | process.exit(1); 56 | } 57 | 58 | async function setup() { 59 | try { 60 | // Clone repo 61 | console.log(`Downloading files from repo ${repo}`); 62 | await runCmd(`git clone --depth 1 ${repo} ${folderName}`); 63 | console.log('Cloned successfully.'); 64 | console.log(''); 65 | 66 | // Change directory 67 | process.chdir(appPath); 68 | 69 | // Install dependencies 70 | const useYarn = await hasYarn(); 71 | console.log('Installing dependencies...'); 72 | if (useYarn) { 73 | await runCmd('yarn install'); 74 | } else { 75 | await runCmd('npm install'); 76 | } 77 | console.log('Dependencies installed successfully.'); 78 | console.log(); 79 | 80 | // Copy envornment variables 81 | fs.copyFileSync(path.join(appPath, '.env.example'), path.join(appPath, '.env')); 82 | console.log('Environment files copied.'); 83 | 84 | // Delete .git folder 85 | await runCmd('npx rimraf ./.git'); 86 | 87 | // Remove extra files 88 | fs.unlinkSync(path.join(appPath, 'CHANGELOG.md')); 89 | fs.unlinkSync(path.join(appPath, 'CODE_OF_CONDUCT.md')); 90 | fs.unlinkSync(path.join(appPath, 'CONTRIBUTING.md')); 91 | if (!useYarn) { 92 | fs.unlinkSync(path.join(appPath, 'yarn.lock')); 93 | } 94 | 95 | console.log('Installation is now complete!'); 96 | console.log(); 97 | 98 | console.log('We suggest that you start by typing:'); 99 | console.log(` cd ${folderName}`); 100 | console.log(useYarn ? ' yarn dev' : ' npm run dev'); 101 | console.log(); 102 | console.log('Enjoy your production-ready Node.js app, which already supports a large number of ready-made features!'); 103 | console.log('Check README.md for more info.'); 104 | } catch (error) { 105 | console.log(error); 106 | } 107 | } 108 | 109 | setup(); -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | mongo: 5 | volumes: 6 | - ./data:/data/db 7 | 8 | app: 9 | container_name: ts-node-app-dev 10 | command: yarn dev -L 11 | 12 | volumes: 13 | mongo-data: 14 | driver: local 15 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | app: 5 | container_name: ts-node-app-prod 6 | build: 7 | target: production 8 | command: yarn start 9 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | app: 5 | container_name: ts-node-app-test 6 | command: yarn test:js 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | mongo: 5 | container_name: mongo 6 | image: mongo:4.2.1-bionic 7 | restart: always 8 | ports: 9 | - "27018:27017" 10 | networks: 11 | - backend 12 | app: 13 | container_name: ts-node-app 14 | build: 15 | context: . 16 | dockerfile: Dockerfile 17 | target: base 18 | restart: always 19 | env_file: .env 20 | expose: 21 | - ${PORT} 22 | ports: 23 | - ${PORT}:${PORT} 24 | environment: 25 | - MONGODB_URL=mongodb://mongo:27017/node-boilerplate 26 | - CLIENT_URL=${CLIENT_URL} 27 | links: 28 | - mongo 29 | depends_on: 30 | - mongo 31 | networks: 32 | - backend 33 | 34 | networks: 35 | backend: 36 | driver: bridge 37 | -------------------------------------------------------------------------------- /ecosystem.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "app", 5 | "script": "dist/index.js", 6 | "node_args": "--experimental-modules --es-module-specifier-resolution=node", 7 | "instances": 1, 8 | "autorestart": true, 9 | "watch": false, 10 | "time": true, 11 | "env": { 12 | "NODE_ENV": "production" 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | testEnvironmentOptions: { 4 | NODE_ENV: 'test', 5 | }, 6 | restoreMocks: true, 7 | coveragePathIgnorePatterns: ['node_modules', 'dist/config', 'dist/app.js'], 8 | coverageReporters: ['text', 'lcov', 'clover', 'html'], 9 | globals: { 10 | 'ts-jest': { 11 | diagnostics: false, 12 | }, 13 | }, 14 | transform: { '\\.ts$': ['ts-jest'] }, 15 | }; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-nodejs-ts-app", 3 | "version": "3.0.11", 4 | "description": "Node express mongoose typescript boilerplate", 5 | "main": "dist/index.js", 6 | "bin": "bin/createNodetsApp.cjs", 7 | "repository": "https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate.git", 8 | "bugs": { 9 | "url": "https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate/issues" 10 | }, 11 | "author": "linus wafula saisi ", 12 | "license": "MIT", 13 | "type": "module", 14 | "engines": { 15 | "node": ">=14.20.1 <19" 16 | }, 17 | "scripts": { 18 | "start": "pm2 start ecosystem.config.json --no-daemon", 19 | "start:build": "tsc --build && pm2 start ecosystem.config.json --no-daemon", 20 | "compile": "tsc --build", 21 | "compile:watch": "tsc --build --watch", 22 | "pre:dev": "cross-env NODE_ENV=development nodemon --experimental-modules --es-module-specifier-resolution=node dist/index.js", 23 | "dev": "concurrently --kill-others \"yarn compile:watch\" \"yarn pre:dev\"", 24 | "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest -i --colors --verbose --detectOpenHandles", 25 | "test:ts": "cross-env NODE_OPTIONS=--experimental-vm-modules jest -i --colors --verbose --detectOpenHandles -- 'ts$'", 26 | "test:js": "cross-env NODE_OPTIONS=--experimental-vm-modules jest -i --colors --verbose --detectOpenHandles -- 'js$'", 27 | "test:watch": "cross-env NODE_OPTIONS=--experimental-vm-modules jest -i --watchAll", 28 | "coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest -i --coverage", 29 | "coverage:coveralls": "cross-env NODE_OPTIONS=--experimental-vm-modules jest -i --coverage --coverageReporters=lcov", 30 | "lint": "eslint .", 31 | "lint:fix": "eslint . --fix", 32 | "prettier": "prettier --check **/*.ts", 33 | "prettier:fix": "prettier --write **/*.ts", 34 | "docker:prod": "docker-compose -f docker-compose.yml -f docker-compose.prod.yml up", 35 | "docker:dev": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml up", 36 | "docker:test": "docker-compose -f docker-compose.yml -f docker-compose.test.yml up", 37 | "prepare": "husky install", 38 | "release": "standard-version && git push --follow-tags origin master", 39 | "commit": "git add -A && cz" 40 | }, 41 | "keywords": [ 42 | "node", 43 | "typescript", 44 | "node.js", 45 | "boilerplate", 46 | "generator", 47 | "express", 48 | "rest", 49 | "api", 50 | "mongodb", 51 | "mongoose", 52 | "es6", 53 | "es7", 54 | "es8", 55 | "es9", 56 | "jest", 57 | "docker", 58 | "passport", 59 | "joi", 60 | "eslint", 61 | "prettier" 62 | ], 63 | "devDependencies": { 64 | "@commitlint/cli": "^16.2.3", 65 | "@commitlint/config-conventional": "^16.2.1", 66 | "@faker-js/faker": "^6.2.0", 67 | "@jest/globals": "^27.5.1", 68 | "@ryansonshine/commitizen": "^4.2.8", 69 | "@ryansonshine/cz-conventional-changelog": "^3.3.4", 70 | "@types/bcryptjs": "^2.4.2", 71 | "@types/compression": "^1.7.2", 72 | "@types/cookie-parser": "^1.4.2", 73 | "@types/cors": "2.8.8", 74 | "@types/express": "^4.17.13", 75 | "@types/express-rate-limit": "^5.1.3", 76 | "@types/jest": "^27.4.1", 77 | "@types/morgan": "^1.9.3", 78 | "@types/node": "^16.11.12", 79 | "@types/nodemailer": "^6.4.4", 80 | "@types/passport-jwt": "^3.0.6", 81 | "@types/supertest": "^2.0.11", 82 | "@types/swagger-jsdoc": "^6.0.1", 83 | "@types/swagger-ui-express": "^4.1.3", 84 | "@types/validator": "^13.7.0", 85 | "@typescript-eslint/eslint-plugin": "^5.7.0", 86 | "@typescript-eslint/parser": "^5.7.0", 87 | "concurrently": "^7.1.0", 88 | "coveralls": "^3.1.1", 89 | "eslint": "^8.4.1", 90 | "eslint-config-airbnb-base": "^15.0.0", 91 | "eslint-config-airbnb-typescript": "^16.1.0", 92 | "eslint-config-prettier": "^8.3.0", 93 | "eslint-plugin-import": "^2.25.3", 94 | "eslint-plugin-jest": "^25.3.0", 95 | "eslint-plugin-prettier": "^4.0.0", 96 | "eslint-plugin-security": "^1.4.0", 97 | "husky": "^7.0.4", 98 | "jest": "^27.4.4", 99 | "lint-staged": "^12.1.2", 100 | "node-mocks-http": "^1.11.0", 101 | "nodemon": "^2.0.20", 102 | "prettier": "^2.5.1", 103 | "standard-version": "^9.3.2", 104 | "supertest": "^6.1.6", 105 | "ts-jest": "^27.1.3", 106 | "typescript": "^4.5.4" 107 | }, 108 | "dependencies": { 109 | "bcryptjs": "^2.4.3", 110 | "compression": "^1.7.4", 111 | "cors": "^2.8.5", 112 | "cross-env": "^7.0.3", 113 | "dotenv": "^10.0.0", 114 | "express": "^4.17.1", 115 | "express-mongo-sanitize": "^2.1.0", 116 | "express-rate-limit": "^5.5.1", 117 | "helmet": "^4.6.0", 118 | "http-status": "^1.5.0", 119 | "joi": "^17.5.0", 120 | "jsonwebtoken": "^9.0.0", 121 | "moment": "^2.29.4", 122 | "mongoose": "^7.0.2", 123 | "morgan": "^1.10.0", 124 | "nodemailer": "^6.7.2", 125 | "passport": "^0.6.0", 126 | "passport-jwt": "^4.0.0", 127 | "pm2": "^5.1.2", 128 | "swagger-jsdoc": "^6.1.0", 129 | "swagger-ui-express": "^4.2.0", 130 | "validator": "^13.7.0", 131 | "winston": "^3.3.3", 132 | "xss-clean": "^0.1.1" 133 | }, 134 | "config": { 135 | "commitizen": { 136 | "path": "./node_modules/@ryansonshine/cz-conventional-changelog" 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /packages/components.yaml: -------------------------------------------------------------------------------- 1 | components: 2 | schemas: 3 | User: 4 | type: object 5 | properties: 6 | id: 7 | type: string 8 | email: 9 | type: string 10 | format: email 11 | name: 12 | type: string 13 | role: 14 | type: string 15 | enum: [user, admin] 16 | example: 17 | id: 5ebac534954b54139806c112 18 | email: fake@example.com 19 | name: fake name 20 | role: user 21 | 22 | Token: 23 | type: object 24 | properties: 25 | token: 26 | type: string 27 | expires: 28 | type: string 29 | format: date-time 30 | example: 31 | token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1ZWJhYzUzNDk1NGI1NDEzOTgwNmMxMTIiLCJpYXQiOjE1ODkyOTg0ODQsImV4cCI6MTU4OTMwMDI4NH0.m1U63blB0MLej_WfB7yC2FTMnCziif9X8yzwDEfJXAg 32 | expires: 2020-05-12T16:18:04.793Z 33 | 34 | AuthTokens: 35 | type: object 36 | properties: 37 | access: 38 | $ref: '#/components/schemas/Token' 39 | refresh: 40 | $ref: '#/components/schemas/Token' 41 | 42 | UserWithTokens: 43 | type: object 44 | properties: 45 | user: 46 | $ref: '#/components/schemas/User' 47 | tokens: 48 | $ref: '#/components/schemas/AuthTokens' 49 | 50 | Error: 51 | type: object 52 | properties: 53 | code: 54 | type: number 55 | message: 56 | type: string 57 | 58 | responses: 59 | DuplicateEmail: 60 | description: Email already taken 61 | content: 62 | application/json: 63 | schema: 64 | $ref: '#/components/schemas/Error' 65 | example: 66 | code: 400 67 | message: Email already taken 68 | Unauthorized: 69 | description: Unauthorized 70 | content: 71 | application/json: 72 | schema: 73 | $ref: '#/components/schemas/Error' 74 | example: 75 | code: 401 76 | message: Please authenticate 77 | Forbidden: 78 | description: Forbidden 79 | content: 80 | application/json: 81 | schema: 82 | $ref: '#/components/schemas/Error' 83 | example: 84 | code: 403 85 | message: Forbidden 86 | NotFound: 87 | description: Not found 88 | content: 89 | application/json: 90 | schema: 91 | $ref: '#/components/schemas/Error' 92 | example: 93 | code: 404 94 | message: Not found 95 | 96 | securitySchemes: 97 | bearerAuth: 98 | type: http 99 | scheme: bearer 100 | bearerFormat: JWT -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import express, { Express } from 'express'; 2 | import helmet from 'helmet'; 3 | import xss from 'xss-clean'; 4 | import ExpressMongoSanitize from 'express-mongo-sanitize'; 5 | import compression from 'compression'; 6 | import cors from 'cors'; 7 | import passport from 'passport'; 8 | import httpStatus from 'http-status'; 9 | import config from './config/config'; 10 | import { morgan } from './modules/logger'; 11 | import { jwtStrategy } from './modules/auth'; 12 | import { authLimiter } from './modules/utils'; 13 | import { ApiError, errorConverter, errorHandler } from './modules/errors'; 14 | import routes from './routes/v1'; 15 | 16 | const app: Express = express(); 17 | 18 | if (config.env !== 'test') { 19 | app.use(morgan.successHandler); 20 | app.use(morgan.errorHandler); 21 | } 22 | 23 | // set security HTTP headers 24 | app.use(helmet()); 25 | 26 | // enable cors 27 | app.use(cors()); 28 | app.options('*', cors()); 29 | 30 | // parse json request body 31 | app.use(express.json()); 32 | 33 | // parse urlencoded request body 34 | app.use(express.urlencoded({ extended: true })); 35 | 36 | // sanitize request data 37 | app.use(xss()); 38 | app.use(ExpressMongoSanitize()); 39 | 40 | // gzip compression 41 | app.use(compression()); 42 | 43 | // jwt authentication 44 | app.use(passport.initialize()); 45 | passport.use('jwt', jwtStrategy); 46 | 47 | // limit repeated failed requests to auth endpoints 48 | if (config.env === 'production') { 49 | app.use('/v1/auth', authLimiter); 50 | } 51 | 52 | // v1 api routes 53 | app.use('/v1', routes); 54 | 55 | // send back a 404 error for any unknown api request 56 | app.use((_req, _res, next) => { 57 | next(new ApiError(httpStatus.NOT_FOUND, 'Not found')); 58 | }); 59 | 60 | // convert error to ApiError, if needed 61 | app.use(errorConverter); 62 | 63 | // handle error 64 | app.use(errorHandler); 65 | 66 | export default app; 67 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | import 'dotenv/config'; 3 | 4 | const envVarsSchema = Joi.object() 5 | .keys({ 6 | NODE_ENV: Joi.string().valid('production', 'development', 'test').required(), 7 | PORT: Joi.number().default(3000), 8 | MONGODB_URL: Joi.string().required().description('Mongo DB url'), 9 | JWT_SECRET: Joi.string().required().description('JWT secret key'), 10 | JWT_ACCESS_EXPIRATION_MINUTES: Joi.number().default(30).description('minutes after which access tokens expire'), 11 | JWT_REFRESH_EXPIRATION_DAYS: Joi.number().default(30).description('days after which refresh tokens expire'), 12 | JWT_RESET_PASSWORD_EXPIRATION_MINUTES: Joi.number() 13 | .default(10) 14 | .description('minutes after which reset password token expires'), 15 | JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: Joi.number() 16 | .default(10) 17 | .description('minutes after which verify email token expires'), 18 | SMTP_HOST: Joi.string().description('server that will send the emails'), 19 | SMTP_PORT: Joi.number().description('port to connect to the email server'), 20 | SMTP_USERNAME: Joi.string().description('username for email server'), 21 | SMTP_PASSWORD: Joi.string().description('password for email server'), 22 | EMAIL_FROM: Joi.string().description('the from field in the emails sent by the app'), 23 | CLIENT_URL: Joi.string().required().description('Client url'), 24 | }) 25 | .unknown(); 26 | 27 | const { value: envVars, error } = envVarsSchema.prefs({ errors: { label: 'key' } }).validate(process.env); 28 | 29 | if (error) { 30 | throw new Error(`Config validation error: ${error.message}`); 31 | } 32 | 33 | const config = { 34 | env: envVars.NODE_ENV, 35 | port: envVars.PORT, 36 | mongoose: { 37 | url: envVars.MONGODB_URL + (envVars.NODE_ENV === 'test' ? '-test' : ''), 38 | options: { 39 | useCreateIndex: true, 40 | useNewUrlParser: true, 41 | useUnifiedTopology: true, 42 | }, 43 | }, 44 | jwt: { 45 | secret: envVars.JWT_SECRET, 46 | accessExpirationMinutes: envVars.JWT_ACCESS_EXPIRATION_MINUTES, 47 | refreshExpirationDays: envVars.JWT_REFRESH_EXPIRATION_DAYS, 48 | resetPasswordExpirationMinutes: envVars.JWT_RESET_PASSWORD_EXPIRATION_MINUTES, 49 | verifyEmailExpirationMinutes: envVars.JWT_VERIFY_EMAIL_EXPIRATION_MINUTES, 50 | cookieOptions: { 51 | httpOnly: true, 52 | secure: envVars.NODE_ENV === 'production', 53 | signed: true, 54 | }, 55 | }, 56 | email: { 57 | smtp: { 58 | host: envVars.SMTP_HOST, 59 | port: envVars.SMTP_PORT, 60 | auth: { 61 | user: envVars.SMTP_USERNAME, 62 | pass: envVars.SMTP_PASSWORD, 63 | }, 64 | }, 65 | from: envVars.EMAIL_FROM, 66 | }, 67 | clientUrl: envVars.CLIENT_URL, 68 | }; 69 | 70 | export default config; 71 | -------------------------------------------------------------------------------- /src/config/roles.ts: -------------------------------------------------------------------------------- 1 | const allRoles = { 2 | user: [], 3 | admin: ['getUsers', 'manageUsers'], 4 | }; 5 | 6 | export const roles: string[] = Object.keys(allRoles); 7 | export const roleRights: Map = new Map(Object.entries(allRoles)); 8 | -------------------------------------------------------------------------------- /src/custom.d.ts: -------------------------------------------------------------------------------- 1 | import { IUserDoc } from './modules/user/user.interfaces'; 2 | 3 | declare module 'express-serve-static-core' { 4 | export interface Request { 5 | user: IUserDoc; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'xss-clean'; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import app from './app'; 3 | import config from './config/config'; 4 | import logger from './modules/logger/logger'; 5 | 6 | let server: any; 7 | mongoose.connect(config.mongoose.url).then(() => { 8 | logger.info('Connected to MongoDB'); 9 | server = app.listen(config.port, () => { 10 | logger.info(`Listening to port ${config.port}`); 11 | }); 12 | }); 13 | 14 | const exitHandler = () => { 15 | if (server) { 16 | server.close(() => { 17 | logger.info('Server closed'); 18 | process.exit(1); 19 | }); 20 | } else { 21 | process.exit(1); 22 | } 23 | }; 24 | 25 | const unexpectedErrorHandler = (error: string) => { 26 | logger.error(error); 27 | exitHandler(); 28 | }; 29 | 30 | process.on('uncaughtException', unexpectedErrorHandler); 31 | process.on('unhandledRejection', unexpectedErrorHandler); 32 | 33 | process.on('SIGTERM', () => { 34 | logger.info('SIGTERM received'); 35 | if (server) { 36 | server.close(); 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /src/modules/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status'; 2 | import { Request, Response } from 'express'; 3 | import catchAsync from '../utils/catchAsync'; 4 | import { tokenService } from '../token'; 5 | import { userService } from '../user'; 6 | import * as authService from './auth.service'; 7 | import { emailService } from '../email'; 8 | 9 | export const register = catchAsync(async (req: Request, res: Response) => { 10 | const user = await userService.registerUser(req.body); 11 | const tokens = await tokenService.generateAuthTokens(user); 12 | res.status(httpStatus.CREATED).send({ user, tokens }); 13 | }); 14 | 15 | export const login = catchAsync(async (req: Request, res: Response) => { 16 | const { email, password } = req.body; 17 | const user = await authService.loginUserWithEmailAndPassword(email, password); 18 | const tokens = await tokenService.generateAuthTokens(user); 19 | res.send({ user, tokens }); 20 | }); 21 | 22 | export const logout = catchAsync(async (req: Request, res: Response) => { 23 | await authService.logout(req.body.refreshToken); 24 | res.status(httpStatus.NO_CONTENT).send(); 25 | }); 26 | 27 | export const refreshTokens = catchAsync(async (req: Request, res: Response) => { 28 | const userWithTokens = await authService.refreshAuth(req.body.refreshToken); 29 | res.send({ ...userWithTokens }); 30 | }); 31 | 32 | export const forgotPassword = catchAsync(async (req: Request, res: Response) => { 33 | const resetPasswordToken = await tokenService.generateResetPasswordToken(req.body.email); 34 | await emailService.sendResetPasswordEmail(req.body.email, resetPasswordToken); 35 | res.status(httpStatus.NO_CONTENT).send(); 36 | }); 37 | 38 | export const resetPassword = catchAsync(async (req: Request, res: Response) => { 39 | await authService.resetPassword(req.query['token'], req.body.password); 40 | res.status(httpStatus.NO_CONTENT).send(); 41 | }); 42 | 43 | export const sendVerificationEmail = catchAsync(async (req: Request, res: Response) => { 44 | const verifyEmailToken = await tokenService.generateVerifyEmailToken(req.user); 45 | await emailService.sendVerificationEmail(req.user.email, verifyEmailToken, req.user.name); 46 | res.status(httpStatus.NO_CONTENT).send(); 47 | }); 48 | 49 | export const verifyEmail = catchAsync(async (req: Request, res: Response) => { 50 | await authService.verifyEmail(req.query['token']); 51 | res.status(httpStatus.NO_CONTENT).send(); 52 | }); 53 | -------------------------------------------------------------------------------- /src/modules/auth/auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import passport from 'passport'; 3 | import httpStatus from 'http-status'; 4 | import ApiError from '../errors/ApiError'; 5 | import { roleRights } from '../../config/roles'; 6 | import { IUserDoc } from '../user/user.interfaces'; 7 | 8 | const verifyCallback = 9 | (req: Request, resolve: any, reject: any, requiredRights: string[]) => 10 | async (err: Error, user: IUserDoc, info: string) => { 11 | if (err || info || !user) { 12 | return reject(new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')); 13 | } 14 | req.user = user; 15 | 16 | if (requiredRights.length) { 17 | const userRights = roleRights.get(user.role); 18 | if (!userRights) return reject(new ApiError(httpStatus.FORBIDDEN, 'Forbidden')); 19 | const hasRequiredRights = requiredRights.every((requiredRight: string) => userRights.includes(requiredRight)); 20 | if (!hasRequiredRights && req.params['userId'] !== user.id) { 21 | return reject(new ApiError(httpStatus.FORBIDDEN, 'Forbidden')); 22 | } 23 | } 24 | 25 | resolve(); 26 | }; 27 | 28 | const authMiddleware = 29 | (...requiredRights: string[]) => 30 | async (req: Request, res: Response, next: NextFunction) => 31 | new Promise((resolve, reject) => { 32 | passport.authenticate('jwt', { session: false }, verifyCallback(req, resolve, reject, requiredRights))(req, res, next); 33 | }) 34 | .then(() => next()) 35 | .catch((err) => next(err)); 36 | 37 | export default authMiddleware; 38 | -------------------------------------------------------------------------------- /src/modules/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status'; 2 | import mongoose from 'mongoose'; 3 | import Token from '../token/token.model'; 4 | import ApiError from '../errors/ApiError'; 5 | import tokenTypes from '../token/token.types'; 6 | import { getUserByEmail, getUserById, updateUserById } from '../user/user.service'; 7 | import { IUserDoc, IUserWithTokens } from '../user/user.interfaces'; 8 | import { generateAuthTokens, verifyToken } from '../token/token.service'; 9 | 10 | /** 11 | * Login with username and password 12 | * @param {string} email 13 | * @param {string} password 14 | * @returns {Promise} 15 | */ 16 | export const loginUserWithEmailAndPassword = async (email: string, password: string): Promise => { 17 | const user = await getUserByEmail(email); 18 | if (!user || !(await user.isPasswordMatch(password))) { 19 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Incorrect email or password'); 20 | } 21 | return user; 22 | }; 23 | 24 | /** 25 | * Logout 26 | * @param {string} refreshToken 27 | * @returns {Promise} 28 | */ 29 | export const logout = async (refreshToken: string): Promise => { 30 | const refreshTokenDoc = await Token.findOne({ token: refreshToken, type: tokenTypes.REFRESH, blacklisted: false }); 31 | if (!refreshTokenDoc) { 32 | throw new ApiError(httpStatus.NOT_FOUND, 'Not found'); 33 | } 34 | await refreshTokenDoc.deleteOne(); 35 | }; 36 | 37 | /** 38 | * Refresh auth tokens 39 | * @param {string} refreshToken 40 | * @returns {Promise} 41 | */ 42 | export const refreshAuth = async (refreshToken: string): Promise => { 43 | try { 44 | const refreshTokenDoc = await verifyToken(refreshToken, tokenTypes.REFRESH); 45 | const user = await getUserById(new mongoose.Types.ObjectId(refreshTokenDoc.user)); 46 | if (!user) { 47 | throw new Error(); 48 | } 49 | await refreshTokenDoc.deleteOne(); 50 | const tokens = await generateAuthTokens(user); 51 | return { user, tokens }; 52 | } catch (error) { 53 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); 54 | } 55 | }; 56 | 57 | /** 58 | * Reset password 59 | * @param {string} resetPasswordToken 60 | * @param {string} newPassword 61 | * @returns {Promise} 62 | */ 63 | export const resetPassword = async (resetPasswordToken: any, newPassword: string): Promise => { 64 | try { 65 | const resetPasswordTokenDoc = await verifyToken(resetPasswordToken, tokenTypes.RESET_PASSWORD); 66 | const user = await getUserById(new mongoose.Types.ObjectId(resetPasswordTokenDoc.user)); 67 | if (!user) { 68 | throw new Error(); 69 | } 70 | await updateUserById(user.id, { password: newPassword }); 71 | await Token.deleteMany({ user: user.id, type: tokenTypes.RESET_PASSWORD }); 72 | } catch (error) { 73 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Password reset failed'); 74 | } 75 | }; 76 | 77 | /** 78 | * Verify email 79 | * @param {string} verifyEmailToken 80 | * @returns {Promise} 81 | */ 82 | export const verifyEmail = async (verifyEmailToken: any): Promise => { 83 | try { 84 | const verifyEmailTokenDoc = await verifyToken(verifyEmailToken, tokenTypes.VERIFY_EMAIL); 85 | const user = await getUserById(new mongoose.Types.ObjectId(verifyEmailTokenDoc.user)); 86 | if (!user) { 87 | throw new Error(); 88 | } 89 | await Token.deleteMany({ user: user.id, type: tokenTypes.VERIFY_EMAIL }); 90 | const updatedUser = await updateUserById(user.id, { isEmailVerified: true }); 91 | return updatedUser; 92 | } catch (error) { 93 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Email verification failed'); 94 | } 95 | }; 96 | -------------------------------------------------------------------------------- /src/modules/auth/auth.validation.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | import { password } from '../validate/custom.validation'; 3 | import { NewRegisteredUser } from '../user/user.interfaces'; 4 | 5 | const registerBody: Record = { 6 | email: Joi.string().required().email(), 7 | password: Joi.string().required().custom(password), 8 | name: Joi.string().required(), 9 | }; 10 | 11 | export const register = { 12 | body: Joi.object().keys(registerBody), 13 | }; 14 | 15 | export const login = { 16 | body: Joi.object().keys({ 17 | email: Joi.string().required(), 18 | password: Joi.string().required(), 19 | }), 20 | }; 21 | 22 | export const logout = { 23 | body: Joi.object().keys({ 24 | refreshToken: Joi.string().required(), 25 | }), 26 | }; 27 | 28 | export const refreshTokens = { 29 | body: Joi.object().keys({ 30 | refreshToken: Joi.string().required(), 31 | }), 32 | }; 33 | 34 | export const forgotPassword = { 35 | body: Joi.object().keys({ 36 | email: Joi.string().email().required(), 37 | }), 38 | }; 39 | 40 | export const resetPassword = { 41 | query: Joi.object().keys({ 42 | token: Joi.string().required(), 43 | }), 44 | body: Joi.object().keys({ 45 | password: Joi.string().required().custom(password), 46 | }), 47 | }; 48 | 49 | export const verifyEmail = { 50 | query: Joi.object().keys({ 51 | token: Joi.string().required(), 52 | }), 53 | }; 54 | -------------------------------------------------------------------------------- /src/modules/auth/index.ts: -------------------------------------------------------------------------------- 1 | import * as authController from './auth.controller'; 2 | import auth from './auth.middleware'; 3 | import * as authService from './auth.service'; 4 | import * as authValidation from './auth.validation'; 5 | import jwtStrategy from './passport'; 6 | 7 | export { authController, auth, authService, authValidation, jwtStrategy }; 8 | -------------------------------------------------------------------------------- /src/modules/auth/passport.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy as JwtStrategy } from 'passport-jwt'; 2 | import tokenTypes from '../token/token.types'; 3 | import config from '../../config/config'; 4 | import User from '../user/user.model'; 5 | import { IPayload } from '../token/token.interfaces'; 6 | 7 | const jwtStrategy = new JwtStrategy( 8 | { 9 | secretOrKey: config.jwt.secret, 10 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 11 | }, 12 | async (payload: IPayload, done) => { 13 | try { 14 | if (payload.type !== tokenTypes.ACCESS) { 15 | throw new Error('Invalid token type'); 16 | } 17 | const user = await User.findById(payload.sub); 18 | if (!user) { 19 | return done(null, false); 20 | } 21 | done(null, user); 22 | } catch (error) { 23 | done(error, false); 24 | } 25 | } 26 | ); 27 | 28 | export default jwtStrategy; 29 | -------------------------------------------------------------------------------- /src/modules/email/email.interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface Message { 2 | from: string; 3 | to: string; 4 | subject: string; 5 | text: string; 6 | html?: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/modules/email/email.service.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from 'nodemailer'; 2 | import config from '../../config/config'; 3 | import logger from '../logger/logger'; 4 | import { Message } from './email.interfaces'; 5 | 6 | export const transport = nodemailer.createTransport(config.email.smtp); 7 | /* istanbul ignore next */ 8 | if (config.env !== 'test') { 9 | transport 10 | .verify() 11 | .then(() => logger.info('Connected to email server')) 12 | .catch(() => logger.warn('Unable to connect to email server. Make sure you have configured the SMTP options in .env')); 13 | } 14 | 15 | /** 16 | * Send an email 17 | * @param {string} to 18 | * @param {string} subject 19 | * @param {string} text 20 | * @param {string} html 21 | * @returns {Promise} 22 | */ 23 | export const sendEmail = async (to: string, subject: string, text: string, html: string): Promise => { 24 | const msg: Message = { 25 | from: config.email.from, 26 | to, 27 | subject, 28 | text, 29 | html, 30 | }; 31 | await transport.sendMail(msg); 32 | }; 33 | 34 | /** 35 | * Send reset password email 36 | * @param {string} to 37 | * @param {string} token 38 | * @returns {Promise} 39 | */ 40 | export const sendResetPasswordEmail = async (to: string, token: string): Promise => { 41 | const subject = 'Reset password'; 42 | // replace this url with the link to the reset password page of your front-end app 43 | const resetPasswordUrl = `http://${config.clientUrl}/reset-password?token=${token}`; 44 | const text = `Hi, 45 | To reset your password, click on this link: ${resetPasswordUrl} 46 | If you did not request any password resets, then ignore this email.`; 47 | const html = `

Dear user,

48 |

To reset your password, click on this link: ${resetPasswordUrl}

49 |

If you did not request any password resets, please ignore this email.

50 |

Thanks,

51 |

Team

`; 52 | await sendEmail(to, subject, text, html); 53 | }; 54 | 55 | /** 56 | * Send verification email 57 | * @param {string} to 58 | * @param {string} token 59 | * @param {string} name 60 | * @returns {Promise} 61 | */ 62 | export const sendVerificationEmail = async (to: string, token: string, name: string): Promise => { 63 | const subject = 'Email Verification'; 64 | // replace this url with the link to the email verification page of your front-end app 65 | const verificationEmailUrl = `http://${config.clientUrl}/verify-email?token=${token}`; 66 | const text = `Hi ${name}, 67 | To verify your email, click on this link: ${verificationEmailUrl} 68 | If you did not create an account, then ignore this email.`; 69 | const html = `

Hi ${name},

70 |

To verify your email, click on this link: ${verificationEmailUrl}

71 |

If you did not create an account, then ignore this email.

`; 72 | await sendEmail(to, subject, text, html); 73 | }; 74 | 75 | /** 76 | * Send email verification after registration 77 | * @param {string} to 78 | * @param {string} token 79 | * @param {string} name 80 | * @returns {Promise} 81 | */ 82 | export const sendSuccessfulRegistration = async (to: string, token: string, name: string): Promise => { 83 | const subject = 'Email Verification'; 84 | // replace this url with the link to the email verification page of your front-end app 85 | const verificationEmailUrl = `http://${config.clientUrl}/verify-email?token=${token}`; 86 | const text = `Hi ${name}, 87 | Congratulations! Your account has been created. 88 | You are almost there. Complete the final step by verifying your email at: ${verificationEmailUrl} 89 | Don't hesitate to contact us if you face any problems 90 | Regards, 91 | Team`; 92 | const html = `

Hi ${name},

93 |

Congratulations! Your account has been created.

94 |

You are almost there. Complete the final step by verifying your email at: ${verificationEmailUrl}

95 |

Don't hesitate to contact us if you face any problems

96 |

Regards,

97 |

Team

`; 98 | await sendEmail(to, subject, text, html); 99 | }; 100 | 101 | /** 102 | * Send email verification after registration 103 | * @param {string} to 104 | * @param {string} name 105 | * @returns {Promise} 106 | */ 107 | export const sendAccountCreated = async (to: string, name: string): Promise => { 108 | const subject = 'Account Created Successfully'; 109 | // replace this url with the link to the email verification page of your front-end app 110 | const loginUrl = `http://${config.clientUrl}/auth/login`; 111 | const text = `Hi ${name}, 112 | Congratulations! Your account has been created successfully. 113 | You can now login at: ${loginUrl} 114 | Don't hesitate to contact us if you face any problems 115 | Regards, 116 | Team`; 117 | const html = `

Hi ${name},

118 |

Congratulations! Your account has been created successfully.

119 |

You can now login at: ${loginUrl}

120 |

Don't hesitate to contact us if you face any problems

121 |

Regards,

122 |

Team

`; 123 | await sendEmail(to, subject, text, html); 124 | }; 125 | -------------------------------------------------------------------------------- /src/modules/email/index.ts: -------------------------------------------------------------------------------- 1 | import * as emailInterfaces from './email.interfaces'; 2 | import * as emailService from './email.service'; 3 | 4 | export { emailInterfaces, emailService }; 5 | -------------------------------------------------------------------------------- /src/modules/errors/ApiError.ts: -------------------------------------------------------------------------------- 1 | class ApiError extends Error { 2 | statusCode: number; 3 | 4 | isOperational: boolean; 5 | 6 | override stack?: string; 7 | 8 | constructor(statusCode: number, message: string, isOperational = true, stack = '') { 9 | super(message); 10 | this.statusCode = statusCode; 11 | this.isOperational = isOperational; 12 | if (stack) { 13 | this.stack = stack; 14 | } else { 15 | Error.captureStackTrace(this, this.constructor); 16 | } 17 | } 18 | } 19 | 20 | export default ApiError; 21 | -------------------------------------------------------------------------------- /src/modules/errors/error.test.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import httpStatus from 'http-status'; 3 | import httpMocks from 'node-mocks-http'; 4 | import { jest } from '@jest/globals'; 5 | import winston from 'winston'; 6 | import { errorConverter, errorHandler } from './error'; 7 | import ApiError from './ApiError'; 8 | import config from '../../config/config'; 9 | import logger from '../logger/logger'; 10 | 11 | describe('Error middlewares', () => { 12 | describe('Error converter', () => { 13 | test('should return the same ApiError object it was called with', () => { 14 | const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); 15 | const next = jest.fn(); 16 | 17 | errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); 18 | 19 | expect(next).toHaveBeenCalledWith(error); 20 | }); 21 | 22 | test('should convert an Error to ApiError and preserve its status and message', () => { 23 | const error = new Error('Any error') as ApiError; 24 | error.statusCode = httpStatus.BAD_REQUEST; 25 | const next = jest.fn(); 26 | 27 | errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); 28 | 29 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 30 | expect(next).toHaveBeenCalledWith( 31 | expect.objectContaining({ 32 | statusCode: error.statusCode, 33 | message: error.message, 34 | isOperational: false, 35 | }) 36 | ); 37 | }); 38 | 39 | test('should convert an Error without status to ApiError with status 500', () => { 40 | const error = new Error('Any error'); 41 | const next = jest.fn(); 42 | 43 | errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); 44 | 45 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 46 | expect(next).toHaveBeenCalledWith( 47 | expect.objectContaining({ 48 | statusCode: httpStatus.INTERNAL_SERVER_ERROR, 49 | message: error.message, 50 | isOperational: false, 51 | }) 52 | ); 53 | }); 54 | 55 | test('should convert an Error without message to ApiError with default message of that http status', () => { 56 | const error = new Error() as ApiError; 57 | error.statusCode = httpStatus.BAD_REQUEST; 58 | const next = jest.fn(); 59 | 60 | errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); 61 | 62 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 63 | expect(next).toHaveBeenCalledWith( 64 | expect.objectContaining({ 65 | statusCode: error.statusCode, 66 | message: httpStatus[error.statusCode], 67 | isOperational: false, 68 | }) 69 | ); 70 | }); 71 | 72 | test('should convert a Mongoose error to ApiError with status 400 and preserve its message', () => { 73 | const error = new mongoose.Error('Any mongoose error'); 74 | const next = jest.fn(); 75 | 76 | errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); 77 | 78 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 79 | expect(next).toHaveBeenCalledWith( 80 | expect.objectContaining({ 81 | statusCode: httpStatus.BAD_REQUEST, 82 | message: error.message, 83 | isOperational: false, 84 | }) 85 | ); 86 | }); 87 | 88 | test('should convert any other object to ApiError with status 500 and its message', () => { 89 | const error = {}; 90 | const next = jest.fn(); 91 | 92 | errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); 93 | 94 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 95 | expect(next).toHaveBeenCalledWith( 96 | expect.objectContaining({ 97 | statusCode: httpStatus.INTERNAL_SERVER_ERROR, 98 | message: httpStatus[httpStatus.INTERNAL_SERVER_ERROR], 99 | isOperational: false, 100 | }) 101 | ); 102 | }); 103 | }); 104 | 105 | describe('Error handler', () => { 106 | beforeEach(() => { 107 | jest.spyOn(logger, 'error').mockImplementation(() => winston.createLogger({})); 108 | }); 109 | 110 | test('should send proper error response and put the error message in res.locals', () => { 111 | const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); 112 | const res = httpMocks.createResponse(); 113 | const next = jest.fn(); 114 | const sendSpy = jest.spyOn(res, 'send'); 115 | 116 | errorHandler(error, httpMocks.createRequest(), res, next); 117 | 118 | expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ code: error.statusCode, message: error.message })); 119 | expect(res.locals['errorMessage']).toBe(error.message); 120 | }); 121 | 122 | test('should put the error stack in the response if in development mode', () => { 123 | config.env = 'development'; 124 | const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); 125 | const res = httpMocks.createResponse(); 126 | const next = jest.fn(); 127 | const sendSpy = jest.spyOn(res, 'send'); 128 | 129 | errorHandler(error, httpMocks.createRequest(), res, next); 130 | 131 | expect(sendSpy).toHaveBeenCalledWith( 132 | expect.objectContaining({ code: error.statusCode, message: error.message, stack: error.stack }) 133 | ); 134 | config.env = process.env['NODE_ENV'] as typeof config.env; 135 | }); 136 | 137 | test('should send internal server error status and message if in production mode and error is not operational', () => { 138 | config.env = 'production'; 139 | const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error', false); 140 | const res = httpMocks.createResponse(); 141 | const next = jest.fn(); 142 | const sendSpy = jest.spyOn(res, 'send'); 143 | 144 | errorHandler(error, httpMocks.createRequest(), res, next); 145 | 146 | expect(sendSpy).toHaveBeenCalledWith( 147 | expect.objectContaining({ 148 | code: httpStatus.INTERNAL_SERVER_ERROR, 149 | message: httpStatus[httpStatus.INTERNAL_SERVER_ERROR], 150 | }) 151 | ); 152 | expect(res.locals['errorMessage']).toBe(error.message); 153 | config.env = process.env['NODE_ENV'] as typeof config.env; 154 | }); 155 | 156 | test('should preserve original error status and message if in production mode and error is operational', () => { 157 | config.env = 'production'; 158 | const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); 159 | const res = httpMocks.createResponse(); 160 | const next = jest.fn(); 161 | const sendSpy = jest.spyOn(res, 'send'); 162 | 163 | errorHandler(error, httpMocks.createRequest(), res, next); 164 | 165 | expect(sendSpy).toHaveBeenCalledWith( 166 | expect.objectContaining({ 167 | code: error.statusCode, 168 | message: error.message, 169 | }) 170 | ); 171 | config.env = process.env['NODE_ENV'] as typeof config.env; 172 | }); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /src/modules/errors/error.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { Request, Response, NextFunction } from 'express'; 3 | import mongoose from 'mongoose'; 4 | import httpStatus from 'http-status'; 5 | import config from '../../config/config'; 6 | import { logger } from '../logger'; 7 | import ApiError from './ApiError'; 8 | 9 | export const errorConverter = (err: any, _req: Request, _res: Response, next: NextFunction) => { 10 | let error = err; 11 | if (!(error instanceof ApiError)) { 12 | const statusCode = 13 | error.statusCode || error instanceof mongoose.Error ? httpStatus.BAD_REQUEST : httpStatus.INTERNAL_SERVER_ERROR; 14 | const message: string = error.message || `${httpStatus[statusCode]}`; 15 | error = new ApiError(statusCode, message, false, err.stack); 16 | } 17 | next(error); 18 | }; 19 | 20 | // eslint-disable-next-line no-unused-vars 21 | export const errorHandler = (err: ApiError, _req: Request, res: Response, _next: NextFunction) => { 22 | let { statusCode, message } = err; 23 | if (config.env === 'production' && !err.isOperational) { 24 | statusCode = httpStatus.INTERNAL_SERVER_ERROR; 25 | message = 'Internal Server Error'; 26 | } 27 | 28 | res.locals['errorMessage'] = err.message; 29 | 30 | const response = { 31 | code: statusCode, 32 | message, 33 | ...(config.env === 'development' && { stack: err.stack }), 34 | }; 35 | 36 | if (config.env === 'development') { 37 | logger.error(err); 38 | } 39 | 40 | res.status(statusCode).send(response); 41 | }; 42 | -------------------------------------------------------------------------------- /src/modules/errors/index.ts: -------------------------------------------------------------------------------- 1 | import ApiError from './ApiError'; 2 | import { errorConverter, errorHandler } from './error'; 3 | 4 | export { ApiError, errorHandler, errorConverter }; 5 | -------------------------------------------------------------------------------- /src/modules/jest/setupTestDB.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import config from '../../config/config'; 3 | 4 | const setupTestDB = () => { 5 | beforeAll(async () => { 6 | await mongoose.connect(config.mongoose.url); 7 | }); 8 | 9 | beforeEach(async () => { 10 | await Promise.all(Object.values(mongoose.connection.collections).map(async (collection) => collection.deleteMany({}))); 11 | }); 12 | 13 | afterAll(async () => { 14 | await mongoose.disconnect(); 15 | }); 16 | }; 17 | 18 | export default setupTestDB; 19 | -------------------------------------------------------------------------------- /src/modules/logger/index.ts: -------------------------------------------------------------------------------- 1 | import logger from './logger'; 2 | import morgan from './morgan'; 3 | 4 | export { logger, morgan }; 5 | -------------------------------------------------------------------------------- /src/modules/logger/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import config from '../../config/config'; 3 | 4 | interface LoggingInfo { 5 | level: string; 6 | message: string; 7 | } 8 | 9 | const enumerateErrorFormat = winston.format((info: LoggingInfo) => { 10 | if (info instanceof Error) { 11 | Object.assign(info, { message: info.stack }); 12 | } 13 | return info; 14 | }); 15 | 16 | const logger = winston.createLogger({ 17 | level: config.env === 'development' ? 'debug' : 'info', 18 | format: winston.format.combine( 19 | enumerateErrorFormat(), 20 | config.env === 'development' ? winston.format.colorize() : winston.format.uncolorize(), 21 | winston.format.splat(), 22 | winston.format.printf((info: LoggingInfo) => `${info.level}: ${info.message}`) 23 | ), 24 | transports: [ 25 | new winston.transports.Console({ 26 | stderrLevels: ['error'], 27 | }), 28 | ], 29 | }); 30 | 31 | export default logger; 32 | -------------------------------------------------------------------------------- /src/modules/logger/morgan.ts: -------------------------------------------------------------------------------- 1 | import morgan from 'morgan'; 2 | import { Request, Response } from 'express'; 3 | import config from '../../config/config'; 4 | import logger from './logger'; 5 | 6 | morgan.token('message', (_req: Request, res: Response) => res.locals['errorMessage'] || ''); 7 | 8 | const getIpFormat = () => (config.env === 'production' ? ':remote-addr - ' : ''); 9 | const successResponseFormat = `${getIpFormat()}:method :url :status - :response-time ms`; 10 | const errorResponseFormat = `${getIpFormat()}:method :url :status - :response-time ms - message: :message`; 11 | 12 | const successHandler = morgan(successResponseFormat, { 13 | skip: (_req: Request, res: Response) => res.statusCode >= 400, 14 | stream: { write: (message: string) => logger.info(message.trim()) }, 15 | }); 16 | 17 | const errorHandler = morgan(errorResponseFormat, { 18 | skip: (_req: Request, res: Response) => res.statusCode < 400, 19 | stream: { write: (message: string) => logger.error(message.trim()) }, 20 | }); 21 | 22 | export default { 23 | successHandler, 24 | errorHandler, 25 | }; 26 | -------------------------------------------------------------------------------- /src/modules/paginate/index.ts: -------------------------------------------------------------------------------- 1 | import paginate from './paginate'; 2 | import * as paginateTypes from './paginate.types'; 3 | 4 | export { paginate, paginateTypes }; 5 | -------------------------------------------------------------------------------- /src/modules/paginate/paginate.test.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import setupTestDB from '../jest/setupTestDB'; 3 | import { toJSON } from '../toJSON'; 4 | import paginate from './paginate'; 5 | import { IProject, IProjectDoc, IProjectModel, ITaskDoc, ITaskModel } from './paginate.types'; 6 | 7 | const projectSchema = new mongoose.Schema({ 8 | name: { 9 | type: String, 10 | required: true, 11 | }, 12 | milestones: { 13 | type: Number, 14 | default: 1, 15 | }, 16 | }); 17 | 18 | projectSchema.virtual('tasks', { 19 | ref: 'Task', 20 | localField: '_id', 21 | foreignField: 'project', 22 | }); 23 | 24 | projectSchema.plugin(paginate); 25 | projectSchema.plugin(toJSON); 26 | const Project = mongoose.model('Project', projectSchema); 27 | 28 | const taskSchema = new mongoose.Schema({ 29 | name: { 30 | type: String, 31 | required: true, 32 | }, 33 | project: { 34 | type: String, 35 | ref: 'Project', 36 | required: true, 37 | }, 38 | }); 39 | 40 | taskSchema.plugin(paginate); 41 | taskSchema.plugin(toJSON); 42 | const Task = mongoose.model('Task', taskSchema); 43 | 44 | setupTestDB(); 45 | 46 | describe('paginate plugin', () => { 47 | describe('populate option', () => { 48 | test('should populate the specified data fields', async () => { 49 | const project = await Project.create({ name: 'Project One' }); 50 | const task = await Task.create({ name: 'Task One', project: project._id }); 51 | 52 | const taskPages = await Task.paginate({ _id: task._id }, { populate: 'project' }); 53 | 54 | expect(taskPages.results[0]).toMatchObject({ project: { _id: project._id, name: project.name } }); 55 | }); 56 | }); 57 | 58 | describe('sortBy option', () => { 59 | test('should sort results in ascending order using createdAt by default', async () => { 60 | const projectOne = await Project.create({ name: 'Project One' }); 61 | const projectTwo = await Project.create({ name: 'Project Two' }); 62 | const projectThree = await Project.create({ name: 'Project Three' }); 63 | 64 | const projectPages = await Project.paginate({}, {}); 65 | 66 | expect(projectPages.results).toHaveLength(3); 67 | expect(projectPages.results[0]).toMatchObject({ name: projectOne.name }); 68 | expect(projectPages.results[1]).toMatchObject({ name: projectTwo.name }); 69 | expect(projectPages.results[2]).toMatchObject({ name: projectThree.name }); 70 | }); 71 | 72 | test('should sort results in ascending order if ascending sort param is specified', async () => { 73 | const projectOne = await Project.create({ name: 'Project One' }); 74 | const projectTwo = await Project.create({ name: 'Project Two', milestones: 2 }); 75 | const projectThree = await Project.create({ name: 'Project Three', milestones: 3 }); 76 | 77 | const projectPages = await Project.paginate({}, { sortBy: 'milestones:asc' }); 78 | 79 | expect(projectPages.results).toHaveLength(3); 80 | expect(projectPages.results[0]).toMatchObject({ name: projectOne.name }); 81 | expect(projectPages.results[1]).toMatchObject({ name: projectTwo.name }); 82 | expect(projectPages.results[2]).toMatchObject({ name: projectThree.name }); 83 | }); 84 | 85 | test('should sort results in descending order if descending sort param is specified', async () => { 86 | const projectOne = await Project.create({ name: 'Project One' }); 87 | const projectTwo = await Project.create({ name: 'Project Two', milestones: 2 }); 88 | const projectThree = await Project.create({ name: 'Project Three', milestones: 3 }); 89 | 90 | const projectPages = await Project.paginate({}, { sortBy: 'milestones:desc' }); 91 | 92 | expect(projectPages.results).toHaveLength(3); 93 | expect(projectPages.results[0]).toMatchObject({ name: projectThree.name }); 94 | expect(projectPages.results[1]).toMatchObject({ name: projectTwo.name }); 95 | expect(projectPages.results[2]).toMatchObject({ name: projectOne.name }); 96 | }); 97 | }); 98 | 99 | describe('limit option', () => { 100 | const projects: IProject[] = [ 101 | { name: 'Project One', milestones: 1 }, 102 | { name: 'Project Two', milestones: 2 }, 103 | { name: 'Project Three', milestones: 3 }, 104 | ]; 105 | beforeEach(async () => { 106 | await Project.insertMany(projects); 107 | }); 108 | 109 | test('should limit returned results if limit param is specified', async () => { 110 | const projectPages = await Project.paginate({}, { limit: 2 }); 111 | 112 | expect(projectPages.results).toHaveLength(2); 113 | expect(projectPages.results[0]).toMatchObject({ name: 'Project One' }); 114 | expect(projectPages.results[1]).toMatchObject({ name: 'Project Two' }); 115 | }); 116 | }); 117 | 118 | describe('page option', () => { 119 | const projects: IProject[] = [ 120 | { name: 'Project One', milestones: 1 }, 121 | { name: 'Project Two', milestones: 2 }, 122 | { name: 'Project Three', milestones: 3 }, 123 | ]; 124 | beforeEach(async () => { 125 | await Project.insertMany(projects); 126 | }); 127 | 128 | test('should return the correct page if page and limit params are specified', async () => { 129 | const projectPages = await Project.paginate({}, { limit: 2, page: 2 }); 130 | 131 | expect(projectPages.results).toHaveLength(1); 132 | expect(projectPages.results[0]).toMatchObject({ name: 'Project Three' }); 133 | }); 134 | }); 135 | 136 | describe('projectBy option', () => { 137 | const projects: IProject[] = [ 138 | { name: 'Project One', milestones: 1 }, 139 | { name: 'Project Two', milestones: 2 }, 140 | { name: 'Project Three', milestones: 3 }, 141 | ]; 142 | beforeEach(async () => { 143 | await Project.insertMany(projects); 144 | }); 145 | 146 | test('should exclude a field when the hide param is specified', async () => { 147 | const projectPages = await Project.paginate({}, { projectBy: 'milestones:hide' }); 148 | 149 | expect(projectPages.results[0]).not.toMatchObject({ milestones: expect.any(Number) }); 150 | }); 151 | 152 | test('should exclude multiple fields when the hide param is specified', async () => { 153 | const projectPages = await Project.paginate({}, { projectBy: 'milestones:hide,name:hide' }); 154 | 155 | expect(projectPages.results[0]).not.toMatchObject({ milestones: expect.any(Number), name: expect.any(String) }); 156 | }); 157 | 158 | test('should include a field when the include param is specified', async () => { 159 | const projectPages = await Project.paginate({}, { projectBy: 'milestones:include' }); 160 | 161 | expect(projectPages.results[0]).not.toMatchObject({ name: expect.any(String) }); 162 | expect(projectPages.results[0]).toMatchObject({ milestones: expect.any(Number) }); 163 | }); 164 | 165 | test('should include multiple fields when the include param is specified', async () => { 166 | const projectPages = await Project.paginate({}, { projectBy: 'milestones:include,name:include' }); 167 | 168 | expect(projectPages.results[0]).toHaveProperty('milestones'); 169 | expect(projectPages.results[0]).toHaveProperty('name'); 170 | }); 171 | 172 | test('should always include id when the include param is specified', async () => { 173 | const projectPages = await Project.paginate({}, { projectBy: 'milestones:include' }); 174 | 175 | expect(projectPages.results[0]).not.toMatchObject({ name: expect.any(String) }); 176 | expect(projectPages.results[0]).toMatchObject({ id: expect.any(String), milestones: expect.any(Number) }); 177 | }); 178 | }); 179 | }); 180 | -------------------------------------------------------------------------------- /src/modules/paginate/paginate.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { Schema, Document, Model } from 'mongoose'; 3 | 4 | export interface QueryResult { 5 | results: Document[]; 6 | page: number; 7 | limit: number; 8 | totalPages: number; 9 | totalResults: number; 10 | } 11 | 12 | export interface IOptions { 13 | sortBy?: string; 14 | projectBy?: string; 15 | populate?: string; 16 | limit?: number; 17 | page?: number; 18 | } 19 | 20 | const paginate = >(schema: Schema): void => { 21 | /** 22 | * @typedef {Object} QueryResult 23 | * @property {Document[]} results - Results found 24 | * @property {number} page - Current page 25 | * @property {number} limit - Maximum number of results per page 26 | * @property {number} totalPages - Total number of pages 27 | * @property {number} totalResults - Total number of documents 28 | */ 29 | /** 30 | * Query for documents with pagination 31 | * @param {Object} [filter] - Mongo filter 32 | * @param {Object} [options] - Query options 33 | * @param {string} [options.sortBy] - Sorting criteria using the format: sortField:(desc|asc). Multiple sorting criteria should be separated by commas (,) 34 | * @param {string} [options.populate] - Populate data fields. Hierarchy of fields should be separated by (.). Multiple populating criteria should be separated by commas (,) 35 | * @param {number} [options.limit] - Maximum number of results per page (default = 10) 36 | * @param {number} [options.page] - Current page (default = 1) 37 | * @param {string} [options.projectBy] - Fields to hide or include (default = '') 38 | * @returns {Promise} 39 | */ 40 | schema.static('paginate', async function (filter: Record, options: IOptions): Promise { 41 | let sort: string = ''; 42 | if (options.sortBy) { 43 | const sortingCriteria: any = []; 44 | options.sortBy.split(',').forEach((sortOption: string) => { 45 | const [key, order] = sortOption.split(':'); 46 | sortingCriteria.push((order === 'desc' ? '-' : '') + key); 47 | }); 48 | sort = sortingCriteria.join(' '); 49 | } else { 50 | sort = 'createdAt'; 51 | } 52 | 53 | let project: string = ''; 54 | if (options.projectBy) { 55 | const projectionCriteria: string[] = []; 56 | options.projectBy.split(',').forEach((projectOption) => { 57 | const [key, include] = projectOption.split(':'); 58 | projectionCriteria.push((include === 'hide' ? '-' : '') + key); 59 | }); 60 | project = projectionCriteria.join(' '); 61 | } else { 62 | project = '-createdAt -updatedAt'; 63 | } 64 | 65 | const limit = options.limit && parseInt(options.limit.toString(), 10) > 0 ? parseInt(options.limit.toString(), 10) : 10; 66 | const page = options.page && parseInt(options.page.toString(), 10) > 0 ? parseInt(options.page.toString(), 10) : 1; 67 | const skip = (page - 1) * limit; 68 | 69 | const countPromise = this.countDocuments(filter).exec(); 70 | let docsPromise = this.find(filter).sort(sort).skip(skip).limit(limit).select(project); 71 | 72 | if (options.populate) { 73 | options.populate.split(',').forEach((populateOption: any) => { 74 | docsPromise = docsPromise.populate( 75 | populateOption 76 | .split('.') 77 | .reverse() 78 | .reduce((a: string, b: string) => ({ path: b, populate: a })) 79 | ); 80 | }); 81 | } 82 | 83 | docsPromise = docsPromise.exec(); 84 | 85 | return Promise.all([countPromise, docsPromise]).then((values) => { 86 | const [totalResults, results] = values; 87 | const totalPages = Math.ceil(totalResults / limit); 88 | const result = { 89 | results, 90 | page, 91 | limit, 92 | totalPages, 93 | totalResults, 94 | }; 95 | return Promise.resolve(result); 96 | }); 97 | }); 98 | }; 99 | 100 | export default paginate; 101 | -------------------------------------------------------------------------------- /src/modules/paginate/paginate.types.ts: -------------------------------------------------------------------------------- 1 | import { Model, Document } from 'mongoose'; 2 | import { QueryResult, IOptions } from './paginate'; 3 | 4 | export interface IProject { 5 | name: string; 6 | milestones: number; 7 | } 8 | 9 | export interface ITask { 10 | name: string; 11 | project: string; 12 | } 13 | 14 | export interface IProjectDoc extends IProject, Document {} 15 | export interface ITaskDoc extends ITask, Document {} 16 | 17 | export interface IProjectModel extends Model { 18 | paginate(filter: Record, options: IOptions): Promise; 19 | } 20 | export interface ITaskModel extends Model { 21 | paginate(filter: Record, options: IOptions): Promise; 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/swagger/swagger.definition.ts: -------------------------------------------------------------------------------- 1 | import config from '../../config/config'; 2 | 3 | const swaggerDefinition = { 4 | openapi: '3.0.0', 5 | info: { 6 | title: 'node-express-typescript-boilerplate API documentation', 7 | version: '0.0.1', 8 | description: 'This is a node express mongoose boilerplate in typescript', 9 | license: { 10 | name: 'MIT', 11 | url: 'https://github.com/saisilinus/node-express-mongoose-typescript-boilerplate.git', 12 | }, 13 | }, 14 | servers: [ 15 | { 16 | url: `http://localhost:${config.port}/v1`, 17 | description: 'Development Server', 18 | }, 19 | ], 20 | }; 21 | 22 | export default swaggerDefinition; 23 | -------------------------------------------------------------------------------- /src/modules/toJSON/index.ts: -------------------------------------------------------------------------------- 1 | import toJSON from './toJSON'; 2 | 3 | // eslint-disable-next-line import/prefer-default-export 4 | export { toJSON }; 5 | -------------------------------------------------------------------------------- /src/modules/toJSON/toJSON.test.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Model, Document } from 'mongoose'; 2 | import { toJSON } from '.'; 3 | 4 | interface SampleSchema { 5 | public: string; 6 | private: string; 7 | nested: { 8 | private: string; 9 | }; 10 | } 11 | 12 | interface SampleSchemaDoc extends SampleSchema, Document {} 13 | interface SampleSchemaModel extends Model {} 14 | 15 | describe('toJSON plugin', () => { 16 | let connection: mongoose.Connection; 17 | 18 | beforeEach(() => { 19 | connection = mongoose.createConnection(); 20 | }); 21 | 22 | it('should replace _id with id', () => { 23 | const schema = new mongoose.Schema(); 24 | schema.plugin(toJSON); 25 | const SampleModel = connection.model('Model', schema); 26 | const doc = new SampleModel(); 27 | expect(doc.toJSON()).not.toHaveProperty('_id'); 28 | expect(doc.toJSON()).toHaveProperty('id', doc._id.toString()); 29 | }); 30 | 31 | it('should remove __v', () => { 32 | const schema = new mongoose.Schema(); 33 | schema.plugin(toJSON); 34 | const SampleModel = connection.model('Model', schema); 35 | const doc = new SampleModel(); 36 | expect(doc.toJSON()).not.toHaveProperty('__v'); 37 | }); 38 | 39 | it('should remove createdAt and updatedAt', () => { 40 | const schema = new mongoose.Schema({}, { timestamps: true }); 41 | schema.plugin(toJSON); 42 | const SampleModel = connection.model('Model', schema); 43 | const doc = new SampleModel(); 44 | expect(doc.toJSON()).not.toHaveProperty('createdAt'); 45 | expect(doc.toJSON()).not.toHaveProperty('updatedAt'); 46 | }); 47 | 48 | it('should remove any path set as private', () => { 49 | const schema = new mongoose.Schema({ 50 | public: { type: String }, 51 | private: { type: String, private: true }, 52 | }); 53 | schema.plugin(toJSON); 54 | const SampleModel = connection.model('Model', schema); 55 | const doc = new SampleModel({ public: 'some public value', private: 'some private value' }); 56 | expect(doc.toJSON()).not.toHaveProperty('private'); 57 | expect(doc.toJSON()).toHaveProperty('public'); 58 | }); 59 | 60 | it('should remove any nested paths set as private', () => { 61 | const schema = new mongoose.Schema({ 62 | public: { type: String }, 63 | nested: { 64 | private: { type: String, private: true }, 65 | }, 66 | }); 67 | schema.plugin(toJSON); 68 | const SampleModel = connection.model('Model', schema); 69 | const doc = new SampleModel({ 70 | public: 'some public value', 71 | nested: { 72 | private: 'some nested private value', 73 | }, 74 | }); 75 | expect(doc.toJSON()).not.toHaveProperty('nested.private'); 76 | expect(doc.toJSON()).toHaveProperty('public'); 77 | }); 78 | 79 | it('should also call the schema toJSON transform function', () => { 80 | const schema = new mongoose.Schema( 81 | { 82 | public: { type: String }, 83 | private: { type: String }, 84 | }, 85 | { 86 | toJSON: { 87 | transform: (_doc, ret) => { 88 | // eslint-disable-next-line no-param-reassign, @typescript-eslint/dot-notation 89 | delete ret['private']; 90 | }, 91 | }, 92 | } 93 | ); 94 | schema.plugin(toJSON); 95 | const SampleModel = connection.model('Model', schema); 96 | const doc = new SampleModel({ public: 'some public value', private: 'some private value' }); 97 | expect(doc.toJSON()).not.toHaveProperty('private'); 98 | expect(doc.toJSON()).toHaveProperty('public'); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/modules/toJSON/toJSON.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { Document } from 'mongoose'; 3 | 4 | /** 5 | * A mongoose schema plugin which applies the following in the toJSON transform call: 6 | * - removes __v, createdAt, updatedAt, and any path that has private: true 7 | * - replaces _id with id 8 | */ 9 | 10 | const deleteAtPath = (obj: any, path: any, index: number) => { 11 | if (index === path.length - 1) { 12 | // eslint-disable-next-line no-param-reassign 13 | delete obj[path[index]]; 14 | return; 15 | } 16 | deleteAtPath(obj[path[index]], path, index + 1); 17 | }; 18 | 19 | const toJSON = (schema: any) => { 20 | let transform: Function; 21 | if (schema.options.toJSON && schema.options.toJSON.transform) { 22 | transform = schema.options.toJSON.transform; 23 | } 24 | 25 | // eslint-disable-next-line no-param-reassign 26 | schema.options.toJSON = Object.assign(schema.options.toJSON || {}, { 27 | transform(doc: Document, ret: any, options: Record) { 28 | Object.keys(schema.paths).forEach((path) => { 29 | if (schema.paths[path].options && schema.paths[path].options.private) { 30 | deleteAtPath(ret, path.split('.'), 0); 31 | } 32 | }); 33 | 34 | // eslint-disable-next-line no-param-reassign 35 | ret.id = ret._id.toString(); 36 | // eslint-disable-next-line no-param-reassign 37 | delete ret._id; 38 | // eslint-disable-next-line no-param-reassign 39 | delete ret.__v; 40 | // eslint-disable-next-line no-param-reassign 41 | delete ret.createdAt; 42 | // eslint-disable-next-line no-param-reassign 43 | delete ret.updatedAt; 44 | if (transform) { 45 | return transform(doc, ret, options); 46 | } 47 | }, 48 | }); 49 | }; 50 | 51 | export default toJSON; 52 | -------------------------------------------------------------------------------- /src/modules/token/index.ts: -------------------------------------------------------------------------------- 1 | import * as tokenService from './token.service'; 2 | import Token from './token.model'; 3 | import * as tokenInterfaces from './token.interfaces'; 4 | import tokenTypes from './token.types'; 5 | 6 | export { tokenService, Token, tokenInterfaces, tokenTypes }; 7 | -------------------------------------------------------------------------------- /src/modules/token/token.interfaces.ts: -------------------------------------------------------------------------------- 1 | import { Document, Model } from 'mongoose'; 2 | import { JwtPayload } from 'jsonwebtoken'; 3 | 4 | export interface IToken { 5 | token: string; 6 | user: string; 7 | type: string; 8 | expires: Date; 9 | blacklisted: boolean; 10 | } 11 | 12 | export type NewToken = Omit; 13 | 14 | export interface ITokenDoc extends IToken, Document {} 15 | 16 | export interface ITokenModel extends Model {} 17 | 18 | export interface IPayload extends JwtPayload { 19 | sub: string; 20 | iat: number; 21 | exp: number; 22 | type: string; 23 | } 24 | 25 | export interface TokenPayload { 26 | token: string; 27 | expires: Date; 28 | } 29 | 30 | export interface AccessAndRefreshTokens { 31 | access: TokenPayload; 32 | refresh: TokenPayload; 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/token/token.model.test.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import mongoose from 'mongoose'; 3 | import { faker } from '@faker-js/faker'; 4 | import config from '../../config/config'; 5 | import { NewToken } from './token.interfaces'; 6 | import tokenTypes from './token.types'; 7 | import Token from './token.model'; 8 | import * as tokenService from './token.service'; 9 | 10 | const password = 'password1'; 11 | const accessTokenExpires = moment().add(config.jwt.accessExpirationMinutes, 'minutes'); 12 | 13 | const userOne = { 14 | _id: new mongoose.Types.ObjectId(), 15 | name: faker.name.findName(), 16 | email: faker.internet.email().toLowerCase(), 17 | password, 18 | role: 'user', 19 | isEmailVerified: false, 20 | }; 21 | 22 | const userOneAccessToken = tokenService.generateToken(userOne._id, accessTokenExpires, tokenTypes.ACCESS); 23 | 24 | describe('Token Model', () => { 25 | const refreshTokenExpires = moment().add(config.jwt.refreshExpirationDays, 'days'); 26 | let newToken: NewToken; 27 | beforeEach(() => { 28 | newToken = { 29 | token: userOneAccessToken, 30 | user: userOne._id.toHexString(), 31 | type: tokenTypes.REFRESH, 32 | expires: refreshTokenExpires.toDate(), 33 | }; 34 | }); 35 | 36 | test('should correctly validate a valid token', async () => { 37 | await expect(new Token(newToken).validate()).resolves.toBeUndefined(); 38 | }); 39 | 40 | test('should throw a validation error if type is unknown', async () => { 41 | newToken.type = 'invalidType'; 42 | await expect(new Token(newToken).validate()).rejects.toThrow(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/modules/token/token.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import tokenTypes from './token.types'; 3 | import toJSON from '../toJSON/toJSON'; 4 | import { ITokenDoc, ITokenModel } from './token.interfaces'; 5 | 6 | const tokenSchema = new mongoose.Schema( 7 | { 8 | token: { 9 | type: String, 10 | required: true, 11 | index: true, 12 | }, 13 | user: { 14 | type: String, 15 | ref: 'User', 16 | required: true, 17 | }, 18 | type: { 19 | type: String, 20 | enum: [tokenTypes.REFRESH, tokenTypes.RESET_PASSWORD, tokenTypes.VERIFY_EMAIL], 21 | required: true, 22 | }, 23 | expires: { 24 | type: Date, 25 | required: true, 26 | }, 27 | blacklisted: { 28 | type: Boolean, 29 | default: false, 30 | }, 31 | }, 32 | { 33 | timestamps: true, 34 | } 35 | ); 36 | 37 | // add plugin that converts mongoose to json 38 | tokenSchema.plugin(toJSON); 39 | 40 | const Token = mongoose.model('Token', tokenSchema); 41 | 42 | export default Token; 43 | -------------------------------------------------------------------------------- /src/modules/token/token.service.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import moment, { Moment } from 'moment'; 3 | import mongoose from 'mongoose'; 4 | import httpStatus from 'http-status'; 5 | import config from '../../config/config'; 6 | import Token from './token.model'; 7 | import ApiError from '../errors/ApiError'; 8 | import tokenTypes from './token.types'; 9 | import { AccessAndRefreshTokens, ITokenDoc } from './token.interfaces'; 10 | import { IUserDoc } from '../user/user.interfaces'; 11 | import { userService } from '../user'; 12 | 13 | /** 14 | * Generate token 15 | * @param {mongoose.Types.ObjectId} userId 16 | * @param {Moment} expires 17 | * @param {string} type 18 | * @param {string} [secret] 19 | * @returns {string} 20 | */ 21 | export const generateToken = ( 22 | userId: mongoose.Types.ObjectId, 23 | expires: Moment, 24 | type: string, 25 | secret: string = config.jwt.secret 26 | ): string => { 27 | const payload = { 28 | sub: userId, 29 | iat: moment().unix(), 30 | exp: expires.unix(), 31 | type, 32 | }; 33 | return jwt.sign(payload, secret); 34 | }; 35 | 36 | /** 37 | * Save a token 38 | * @param {string} token 39 | * @param {mongoose.Types.ObjectId} userId 40 | * @param {Moment} expires 41 | * @param {string} type 42 | * @param {boolean} [blacklisted] 43 | * @returns {Promise} 44 | */ 45 | export const saveToken = async ( 46 | token: string, 47 | userId: mongoose.Types.ObjectId, 48 | expires: Moment, 49 | type: string, 50 | blacklisted: boolean = false 51 | ): Promise => { 52 | const tokenDoc = await Token.create({ 53 | token, 54 | user: userId, 55 | expires: expires.toDate(), 56 | type, 57 | blacklisted, 58 | }); 59 | return tokenDoc; 60 | }; 61 | 62 | /** 63 | * Verify token and return token doc (or throw an error if it is not valid) 64 | * @param {string} token 65 | * @param {string} type 66 | * @returns {Promise} 67 | */ 68 | export const verifyToken = async (token: string, type: string): Promise => { 69 | const payload = jwt.verify(token, config.jwt.secret); 70 | if (typeof payload.sub !== 'string') { 71 | throw new ApiError(httpStatus.BAD_REQUEST, 'bad user'); 72 | } 73 | const tokenDoc = await Token.findOne({ 74 | token, 75 | type, 76 | user: payload.sub, 77 | blacklisted: false, 78 | }); 79 | if (!tokenDoc) { 80 | throw new Error('Token not found'); 81 | } 82 | return tokenDoc; 83 | }; 84 | 85 | /** 86 | * Generate auth tokens 87 | * @param {IUserDoc} user 88 | * @returns {Promise} 89 | */ 90 | export const generateAuthTokens = async (user: IUserDoc): Promise => { 91 | const accessTokenExpires = moment().add(config.jwt.accessExpirationMinutes, 'minutes'); 92 | const accessToken = generateToken(user.id, accessTokenExpires, tokenTypes.ACCESS); 93 | 94 | const refreshTokenExpires = moment().add(config.jwt.refreshExpirationDays, 'days'); 95 | const refreshToken = generateToken(user.id, refreshTokenExpires, tokenTypes.REFRESH); 96 | await saveToken(refreshToken, user.id, refreshTokenExpires, tokenTypes.REFRESH); 97 | 98 | return { 99 | access: { 100 | token: accessToken, 101 | expires: accessTokenExpires.toDate(), 102 | }, 103 | refresh: { 104 | token: refreshToken, 105 | expires: refreshTokenExpires.toDate(), 106 | }, 107 | }; 108 | }; 109 | 110 | /** 111 | * Generate reset password token 112 | * @param {string} email 113 | * @returns {Promise} 114 | */ 115 | export const generateResetPasswordToken = async (email: string): Promise => { 116 | const user = await userService.getUserByEmail(email); 117 | if (!user) { 118 | throw new ApiError(httpStatus.NO_CONTENT, ''); 119 | } 120 | const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); 121 | const resetPasswordToken = generateToken(user.id, expires, tokenTypes.RESET_PASSWORD); 122 | await saveToken(resetPasswordToken, user.id, expires, tokenTypes.RESET_PASSWORD); 123 | return resetPasswordToken; 124 | }; 125 | 126 | /** 127 | * Generate verify email token 128 | * @param {IUserDoc} user 129 | * @returns {Promise} 130 | */ 131 | export const generateVerifyEmailToken = async (user: IUserDoc): Promise => { 132 | const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes'); 133 | const verifyEmailToken = generateToken(user.id, expires, tokenTypes.VERIFY_EMAIL); 134 | await saveToken(verifyEmailToken, user.id, expires, tokenTypes.VERIFY_EMAIL); 135 | return verifyEmailToken; 136 | }; 137 | -------------------------------------------------------------------------------- /src/modules/token/token.types.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | ACCESS: 'access', 3 | REFRESH: 'refresh', 4 | RESET_PASSWORD: 'resetPassword', 5 | VERIFY_EMAIL: 'verifyEmail', 6 | }; 7 | -------------------------------------------------------------------------------- /src/modules/user/index.ts: -------------------------------------------------------------------------------- 1 | import * as userController from './user.controller'; 2 | import * as userInterfaces from './user.interfaces'; 3 | import User from './user.model'; 4 | import * as userService from './user.service'; 5 | import * as userValidation from './user.validation'; 6 | 7 | export { userController, userInterfaces, User, userService, userValidation }; 8 | -------------------------------------------------------------------------------- /src/modules/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status'; 2 | import { Request, Response } from 'express'; 3 | import mongoose from 'mongoose'; 4 | import catchAsync from '../utils/catchAsync'; 5 | import ApiError from '../errors/ApiError'; 6 | import pick from '../utils/pick'; 7 | import { IOptions } from '../paginate/paginate'; 8 | import * as userService from './user.service'; 9 | 10 | export const createUser = catchAsync(async (req: Request, res: Response) => { 11 | const user = await userService.createUser(req.body); 12 | res.status(httpStatus.CREATED).send(user); 13 | }); 14 | 15 | export const getUsers = catchAsync(async (req: Request, res: Response) => { 16 | const filter = pick(req.query, ['name', 'role']); 17 | const options: IOptions = pick(req.query, ['sortBy', 'limit', 'page', 'projectBy']); 18 | const result = await userService.queryUsers(filter, options); 19 | res.send(result); 20 | }); 21 | 22 | export const getUser = catchAsync(async (req: Request, res: Response) => { 23 | if (typeof req.params['userId'] === 'string') { 24 | const user = await userService.getUserById(new mongoose.Types.ObjectId(req.params['userId'])); 25 | if (!user) { 26 | throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); 27 | } 28 | res.send(user); 29 | } 30 | }); 31 | 32 | export const updateUser = catchAsync(async (req: Request, res: Response) => { 33 | if (typeof req.params['userId'] === 'string') { 34 | const user = await userService.updateUserById(new mongoose.Types.ObjectId(req.params['userId']), req.body); 35 | res.send(user); 36 | } 37 | }); 38 | 39 | export const deleteUser = catchAsync(async (req: Request, res: Response) => { 40 | if (typeof req.params['userId'] === 'string') { 41 | await userService.deleteUserById(new mongoose.Types.ObjectId(req.params['userId'])); 42 | res.status(httpStatus.NO_CONTENT).send(); 43 | } 44 | }); 45 | -------------------------------------------------------------------------------- /src/modules/user/user.interfaces.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Model, Document } from 'mongoose'; 2 | import { QueryResult } from '../paginate/paginate'; 3 | import { AccessAndRefreshTokens } from '../token/token.interfaces'; 4 | 5 | export interface IUser { 6 | name: string; 7 | email: string; 8 | password: string; 9 | role: string; 10 | isEmailVerified: boolean; 11 | } 12 | 13 | export interface IUserDoc extends IUser, Document { 14 | isPasswordMatch(password: string): Promise; 15 | } 16 | 17 | export interface IUserModel extends Model { 18 | isEmailTaken(email: string, excludeUserId?: mongoose.Types.ObjectId): Promise; 19 | paginate(filter: Record, options: Record): Promise; 20 | } 21 | 22 | export type UpdateUserBody = Partial; 23 | 24 | export type NewRegisteredUser = Omit; 25 | 26 | export type NewCreatedUser = Omit; 27 | 28 | export interface IUserWithTokens { 29 | user: IUserDoc; 30 | tokens: AccessAndRefreshTokens; 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/user/user.model.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { NewCreatedUser } from './user.interfaces'; 3 | import User from './user.model'; 4 | 5 | describe('User model', () => { 6 | describe('User validation', () => { 7 | let newUser: NewCreatedUser; 8 | beforeEach(() => { 9 | newUser = { 10 | name: faker.name.findName(), 11 | email: faker.internet.email().toLowerCase(), 12 | password: 'password1', 13 | role: 'user', 14 | }; 15 | }); 16 | 17 | test('should correctly validate a valid user', async () => { 18 | await expect(new User(newUser).validate()).resolves.toBeUndefined(); 19 | }); 20 | 21 | test('should throw a validation error if email is invalid', async () => { 22 | newUser.email = 'invalidEmail'; 23 | await expect(new User(newUser).validate()).rejects.toThrow(); 24 | }); 25 | 26 | test('should throw a validation error if password length is less than 8 characters', async () => { 27 | newUser.password = 'passwo1'; 28 | await expect(new User(newUser).validate()).rejects.toThrow(); 29 | }); 30 | 31 | test('should throw a validation error if password does not contain numbers', async () => { 32 | newUser.password = 'password'; 33 | await expect(new User(newUser).validate()).rejects.toThrow(); 34 | }); 35 | 36 | test('should throw a validation error if password does not contain letters', async () => { 37 | newUser.password = '11111111'; 38 | await expect(new User(newUser).validate()).rejects.toThrow(); 39 | }); 40 | 41 | test('should throw a validation error if role is unknown', async () => { 42 | newUser.role = 'invalid'; 43 | await expect(new User(newUser).validate()).rejects.toThrow(); 44 | }); 45 | }); 46 | 47 | describe('User toJSON()', () => { 48 | test('should not return user password when toJSON is called', () => { 49 | const newUser = { 50 | name: faker.name.findName(), 51 | email: faker.internet.email().toLowerCase(), 52 | password: 'password1', 53 | role: 'user', 54 | }; 55 | expect(new User(newUser).toJSON()).not.toHaveProperty('password'); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/modules/user/user.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import validator from 'validator'; 3 | import bcrypt from 'bcryptjs'; 4 | import toJSON from '../toJSON/toJSON'; 5 | import paginate from '../paginate/paginate'; 6 | import { roles } from '../../config/roles'; 7 | import { IUserDoc, IUserModel } from './user.interfaces'; 8 | 9 | const userSchema = new mongoose.Schema( 10 | { 11 | name: { 12 | type: String, 13 | required: true, 14 | trim: true, 15 | }, 16 | email: { 17 | type: String, 18 | required: true, 19 | unique: true, 20 | trim: true, 21 | lowercase: true, 22 | validate(value: string) { 23 | if (!validator.isEmail(value)) { 24 | throw new Error('Invalid email'); 25 | } 26 | }, 27 | }, 28 | password: { 29 | type: String, 30 | required: true, 31 | trim: true, 32 | minlength: 8, 33 | validate(value: string) { 34 | if (!value.match(/\d/) || !value.match(/[a-zA-Z]/)) { 35 | throw new Error('Password must contain at least one letter and one number'); 36 | } 37 | }, 38 | private: true, // used by the toJSON plugin 39 | }, 40 | role: { 41 | type: String, 42 | enum: roles, 43 | default: 'user', 44 | }, 45 | isEmailVerified: { 46 | type: Boolean, 47 | default: false, 48 | }, 49 | }, 50 | { 51 | timestamps: true, 52 | } 53 | ); 54 | 55 | // add plugin that converts mongoose to json 56 | userSchema.plugin(toJSON); 57 | userSchema.plugin(paginate); 58 | 59 | /** 60 | * Check if email is taken 61 | * @param {string} email - The user's email 62 | * @param {ObjectId} [excludeUserId] - The id of the user to be excluded 63 | * @returns {Promise} 64 | */ 65 | userSchema.static('isEmailTaken', async function (email: string, excludeUserId: mongoose.ObjectId): Promise { 66 | const user = await this.findOne({ email, _id: { $ne: excludeUserId } }); 67 | return !!user; 68 | }); 69 | 70 | /** 71 | * Check if password matches the user's password 72 | * @param {string} password 73 | * @returns {Promise} 74 | */ 75 | userSchema.method('isPasswordMatch', async function (password: string): Promise { 76 | const user = this; 77 | return bcrypt.compare(password, user.password); 78 | }); 79 | 80 | userSchema.pre('save', async function (next) { 81 | const user = this; 82 | if (user.isModified('password')) { 83 | user.password = await bcrypt.hash(user.password, 8); 84 | } 85 | next(); 86 | }); 87 | 88 | const User = mongoose.model('User', userSchema); 89 | 90 | export default User; 91 | -------------------------------------------------------------------------------- /src/modules/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status'; 2 | import mongoose from 'mongoose'; 3 | import User from './user.model'; 4 | import ApiError from '../errors/ApiError'; 5 | import { IOptions, QueryResult } from '../paginate/paginate'; 6 | import { NewCreatedUser, UpdateUserBody, IUserDoc, NewRegisteredUser } from './user.interfaces'; 7 | 8 | /** 9 | * Create a user 10 | * @param {NewCreatedUser} userBody 11 | * @returns {Promise} 12 | */ 13 | export const createUser = async (userBody: NewCreatedUser): Promise => { 14 | if (await User.isEmailTaken(userBody.email)) { 15 | throw new ApiError(httpStatus.BAD_REQUEST, 'Email already taken'); 16 | } 17 | return User.create(userBody); 18 | }; 19 | 20 | /** 21 | * Register a user 22 | * @param {NewRegisteredUser} userBody 23 | * @returns {Promise} 24 | */ 25 | export const registerUser = async (userBody: NewRegisteredUser): Promise => { 26 | if (await User.isEmailTaken(userBody.email)) { 27 | throw new ApiError(httpStatus.BAD_REQUEST, 'Email already taken'); 28 | } 29 | return User.create(userBody); 30 | }; 31 | 32 | /** 33 | * Query for users 34 | * @param {Object} filter - Mongo filter 35 | * @param {Object} options - Query options 36 | * @returns {Promise} 37 | */ 38 | export const queryUsers = async (filter: Record, options: IOptions): Promise => { 39 | const users = await User.paginate(filter, options); 40 | return users; 41 | }; 42 | 43 | /** 44 | * Get user by id 45 | * @param {mongoose.Types.ObjectId} id 46 | * @returns {Promise} 47 | */ 48 | export const getUserById = async (id: mongoose.Types.ObjectId): Promise => User.findById(id); 49 | 50 | /** 51 | * Get user by email 52 | * @param {string} email 53 | * @returns {Promise} 54 | */ 55 | export const getUserByEmail = async (email: string): Promise => User.findOne({ email }); 56 | 57 | /** 58 | * Update user by id 59 | * @param {mongoose.Types.ObjectId} userId 60 | * @param {UpdateUserBody} updateBody 61 | * @returns {Promise} 62 | */ 63 | export const updateUserById = async ( 64 | userId: mongoose.Types.ObjectId, 65 | updateBody: UpdateUserBody 66 | ): Promise => { 67 | const user = await getUserById(userId); 68 | if (!user) { 69 | throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); 70 | } 71 | if (updateBody.email && (await User.isEmailTaken(updateBody.email, userId))) { 72 | throw new ApiError(httpStatus.BAD_REQUEST, 'Email already taken'); 73 | } 74 | Object.assign(user, updateBody); 75 | await user.save(); 76 | return user; 77 | }; 78 | 79 | /** 80 | * Delete user by id 81 | * @param {mongoose.Types.ObjectId} userId 82 | * @returns {Promise} 83 | */ 84 | export const deleteUserById = async (userId: mongoose.Types.ObjectId): Promise => { 85 | const user = await getUserById(userId); 86 | if (!user) { 87 | throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); 88 | } 89 | await user.deleteOne(); 90 | return user; 91 | }; 92 | -------------------------------------------------------------------------------- /src/modules/user/user.test.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import bcrypt from 'bcryptjs'; 3 | import request from 'supertest'; 4 | import { faker } from '@faker-js/faker'; 5 | import httpStatus from 'http-status'; 6 | import moment from 'moment'; 7 | import config from '../../config/config'; 8 | import tokenTypes from '../token/token.types'; 9 | import * as tokenService from '../token/token.service'; 10 | import app from '../../app'; 11 | import setupTestDB from '../jest/setupTestDB'; 12 | import User from './user.model'; 13 | import { NewCreatedUser } from './user.interfaces'; 14 | 15 | setupTestDB(); 16 | 17 | const password = 'password1'; 18 | const salt = bcrypt.genSaltSync(8); 19 | const hashedPassword = bcrypt.hashSync(password, salt); 20 | const accessTokenExpires = moment().add(config.jwt.accessExpirationMinutes, 'minutes'); 21 | 22 | const userOne = { 23 | _id: new mongoose.Types.ObjectId(), 24 | name: faker.name.findName(), 25 | email: faker.internet.email().toLowerCase(), 26 | password, 27 | role: 'user', 28 | isEmailVerified: false, 29 | }; 30 | 31 | const userTwo = { 32 | _id: new mongoose.Types.ObjectId(), 33 | name: faker.name.findName(), 34 | email: faker.internet.email().toLowerCase(), 35 | password, 36 | role: 'user', 37 | isEmailVerified: false, 38 | }; 39 | 40 | const admin = { 41 | _id: new mongoose.Types.ObjectId(), 42 | name: faker.name.findName(), 43 | email: faker.internet.email().toLowerCase(), 44 | password, 45 | role: 'admin', 46 | isEmailVerified: false, 47 | }; 48 | 49 | const userOneAccessToken = tokenService.generateToken(userOne._id, accessTokenExpires, tokenTypes.ACCESS); 50 | const adminAccessToken = tokenService.generateToken(admin._id, accessTokenExpires, tokenTypes.ACCESS); 51 | 52 | const insertUsers = async (users: Record[]) => { 53 | await User.insertMany(users.map((user) => ({ ...user, password: hashedPassword }))); 54 | }; 55 | 56 | describe('User routes', () => { 57 | describe('POST /v1/users', () => { 58 | let newUser: NewCreatedUser; 59 | 60 | beforeEach(() => { 61 | newUser = { 62 | name: faker.name.findName(), 63 | email: faker.internet.email().toLowerCase(), 64 | password: 'password1', 65 | role: 'user', 66 | }; 67 | }); 68 | 69 | test('should return 201 and successfully create new user if data is ok', async () => { 70 | await insertUsers([admin]); 71 | 72 | const res = await request(app) 73 | .post('/v1/users') 74 | .set('Authorization', `Bearer ${adminAccessToken}`) 75 | .send(newUser) 76 | .expect(httpStatus.CREATED); 77 | 78 | expect(res.body).not.toHaveProperty('password'); 79 | expect(res.body).toEqual({ 80 | id: expect.anything(), 81 | name: newUser.name, 82 | email: newUser.email, 83 | role: newUser.role, 84 | isEmailVerified: false, 85 | }); 86 | 87 | const dbUser = await User.findById(res.body.id); 88 | expect(dbUser).toBeDefined(); 89 | if (!dbUser) return; 90 | 91 | expect(dbUser.password).not.toBe(newUser.password); 92 | expect(dbUser).toMatchObject({ name: newUser.name, email: newUser.email, role: newUser.role, isEmailVerified: false }); 93 | }); 94 | 95 | test('should be able to create an admin as well', async () => { 96 | await insertUsers([admin]); 97 | newUser.role = 'admin'; 98 | 99 | const res = await request(app) 100 | .post('/v1/users') 101 | .set('Authorization', `Bearer ${adminAccessToken}`) 102 | .send(newUser) 103 | .expect(httpStatus.CREATED); 104 | 105 | expect(res.body.role).toBe('admin'); 106 | 107 | const dbUser = await User.findById(res.body.id); 108 | expect(dbUser).toBeDefined(); 109 | if (!dbUser) return; 110 | expect(dbUser.role).toBe('admin'); 111 | }); 112 | 113 | test('should return 401 error if access token is missing', async () => { 114 | await request(app).post('/v1/users').send(newUser).expect(httpStatus.UNAUTHORIZED); 115 | }); 116 | 117 | test('should return 403 error if logged in user is not admin', async () => { 118 | await insertUsers([userOne]); 119 | 120 | await request(app) 121 | .post('/v1/users') 122 | .set('Authorization', `Bearer ${userOneAccessToken}`) 123 | .send(newUser) 124 | .expect(httpStatus.FORBIDDEN); 125 | }); 126 | 127 | test('should return 400 error if email is invalid', async () => { 128 | await insertUsers([admin]); 129 | newUser.email = 'invalidEmail'; 130 | 131 | await request(app) 132 | .post('/v1/users') 133 | .set('Authorization', `Bearer ${adminAccessToken}`) 134 | .send(newUser) 135 | .expect(httpStatus.BAD_REQUEST); 136 | }); 137 | 138 | test('should return 400 error if email is already used', async () => { 139 | await insertUsers([admin, userOne]); 140 | newUser.email = userOne.email; 141 | 142 | await request(app) 143 | .post('/v1/users') 144 | .set('Authorization', `Bearer ${adminAccessToken}`) 145 | .send(newUser) 146 | .expect(httpStatus.BAD_REQUEST); 147 | }); 148 | 149 | test('should return 400 error if password length is less than 8 characters', async () => { 150 | await insertUsers([admin]); 151 | newUser.password = 'passwo1'; 152 | 153 | await request(app) 154 | .post('/v1/users') 155 | .set('Authorization', `Bearer ${adminAccessToken}`) 156 | .send(newUser) 157 | .expect(httpStatus.BAD_REQUEST); 158 | }); 159 | 160 | test('should return 400 error if password does not contain both letters and numbers', async () => { 161 | await insertUsers([admin]); 162 | newUser.password = 'password'; 163 | 164 | await request(app) 165 | .post('/v1/users') 166 | .set('Authorization', `Bearer ${adminAccessToken}`) 167 | .send(newUser) 168 | .expect(httpStatus.BAD_REQUEST); 169 | 170 | newUser.password = '1111111'; 171 | 172 | await request(app) 173 | .post('/v1/users') 174 | .set('Authorization', `Bearer ${adminAccessToken}`) 175 | .send(newUser) 176 | .expect(httpStatus.BAD_REQUEST); 177 | }); 178 | 179 | test('should return 400 error if role is neither user nor admin', async () => { 180 | await insertUsers([admin]); 181 | (newUser as any).role = 'invalid'; 182 | 183 | await request(app) 184 | .post('/v1/users') 185 | .set('Authorization', `Bearer ${adminAccessToken}`) 186 | .send(newUser) 187 | .expect(httpStatus.BAD_REQUEST); 188 | }); 189 | }); 190 | 191 | describe('GET /v1/users', () => { 192 | test('should return 200 and apply the default query options', async () => { 193 | await insertUsers([userOne, userTwo, admin]); 194 | 195 | const res = await request(app) 196 | .get('/v1/users') 197 | .set('Authorization', `Bearer ${adminAccessToken}`) 198 | .send() 199 | .expect(httpStatus.OK); 200 | 201 | expect(res.body).toEqual({ 202 | results: expect.any(Array), 203 | page: 1, 204 | limit: 10, 205 | totalPages: 1, 206 | totalResults: 3, 207 | }); 208 | expect(res.body.results).toHaveLength(3); 209 | expect(res.body.results[0]).toEqual({ 210 | id: userOne._id.toHexString(), 211 | name: userOne.name, 212 | email: userOne.email, 213 | role: userOne.role, 214 | isEmailVerified: userOne.isEmailVerified, 215 | }); 216 | }); 217 | 218 | test('should return 401 if access token is missing', async () => { 219 | await insertUsers([userOne, userTwo, admin]); 220 | 221 | await request(app).get('/v1/users').send().expect(httpStatus.UNAUTHORIZED); 222 | }); 223 | 224 | test('should return 403 if a non-admin is trying to access all users', async () => { 225 | await insertUsers([userOne, userTwo, admin]); 226 | 227 | await request(app) 228 | .get('/v1/users') 229 | .set('Authorization', `Bearer ${userOneAccessToken}`) 230 | .send() 231 | .expect(httpStatus.FORBIDDEN); 232 | }); 233 | 234 | test('should correctly apply filter on name field', async () => { 235 | await insertUsers([userOne, userTwo, admin]); 236 | 237 | const res = await request(app) 238 | .get('/v1/users') 239 | .set('Authorization', `Bearer ${adminAccessToken}`) 240 | .query({ name: userOne.name }) 241 | .send() 242 | .expect(httpStatus.OK); 243 | 244 | expect(res.body).toEqual({ 245 | results: expect.any(Array), 246 | page: 1, 247 | limit: 10, 248 | totalPages: 1, 249 | totalResults: 1, 250 | }); 251 | expect(res.body.results).toHaveLength(1); 252 | expect(res.body.results[0].id).toBe(userOne._id.toHexString()); 253 | }); 254 | 255 | test('should correctly apply filter on role field', async () => { 256 | await insertUsers([userOne, userTwo, admin]); 257 | 258 | const res = await request(app) 259 | .get('/v1/users') 260 | .set('Authorization', `Bearer ${adminAccessToken}`) 261 | .query({ role: 'user' }) 262 | .send() 263 | .expect(httpStatus.OK); 264 | 265 | expect(res.body).toEqual({ 266 | results: expect.any(Array), 267 | page: 1, 268 | limit: 10, 269 | totalPages: 1, 270 | totalResults: 2, 271 | }); 272 | expect(res.body.results).toHaveLength(2); 273 | expect(res.body.results[0].id).toBe(userOne._id.toHexString()); 274 | expect(res.body.results[1].id).toBe(userTwo._id.toHexString()); 275 | }); 276 | 277 | test('should correctly sort the returned array if descending sort param is specified', async () => { 278 | await insertUsers([userOne, userTwo, admin]); 279 | 280 | const res = await request(app) 281 | .get('/v1/users') 282 | .set('Authorization', `Bearer ${adminAccessToken}`) 283 | .query({ sortBy: 'role:desc' }) 284 | .send() 285 | .expect(httpStatus.OK); 286 | 287 | expect(res.body).toEqual({ 288 | results: expect.any(Array), 289 | page: 1, 290 | limit: 10, 291 | totalPages: 1, 292 | totalResults: 3, 293 | }); 294 | expect(res.body.results).toHaveLength(3); 295 | expect(res.body.results[0].id).toBe(userOne._id.toHexString()); 296 | expect(res.body.results[1].id).toBe(userTwo._id.toHexString()); 297 | expect(res.body.results[2].id).toBe(admin._id.toHexString()); 298 | }); 299 | 300 | test('should correctly sort the returned array if ascending sort param is specified', async () => { 301 | await insertUsers([userOne, userTwo, admin]); 302 | 303 | const res = await request(app) 304 | .get('/v1/users') 305 | .set('Authorization', `Bearer ${adminAccessToken}`) 306 | .query({ sortBy: 'role:asc' }) 307 | .send() 308 | .expect(httpStatus.OK); 309 | 310 | expect(res.body).toEqual({ 311 | results: expect.any(Array), 312 | page: 1, 313 | limit: 10, 314 | totalPages: 1, 315 | totalResults: 3, 316 | }); 317 | expect(res.body.results).toHaveLength(3); 318 | expect(res.body.results[0].id).toBe(admin._id.toHexString()); 319 | expect(res.body.results[1].id).toBe(userOne._id.toHexString()); 320 | expect(res.body.results[2].id).toBe(userTwo._id.toHexString()); 321 | }); 322 | 323 | test('should correctly sort the returned array if multiple sorting criteria are specified', async () => { 324 | await insertUsers([userOne, userTwo, admin]); 325 | 326 | const res = await request(app) 327 | .get('/v1/users') 328 | .set('Authorization', `Bearer ${adminAccessToken}`) 329 | .query({ sortBy: 'role:desc,name:asc' }) 330 | .send() 331 | .expect(httpStatus.OK); 332 | 333 | expect(res.body).toEqual({ 334 | results: expect.any(Array), 335 | page: 1, 336 | limit: 10, 337 | totalPages: 1, 338 | totalResults: 3, 339 | }); 340 | expect(res.body.results).toHaveLength(3); 341 | 342 | const expectedOrder = [userOne, userTwo, admin].sort((a, b) => { 343 | if (a.role! < b.role!) { 344 | return 1; 345 | } 346 | if (a.role! > b.role!) { 347 | return -1; 348 | } 349 | return a.name < b.name ? -1 : 1; 350 | }); 351 | 352 | expectedOrder.forEach((user, index) => { 353 | expect(res.body.results[index].id).toBe(user._id.toHexString()); 354 | }); 355 | }); 356 | 357 | test('should limit returned array if limit param is specified', async () => { 358 | await insertUsers([userOne, userTwo, admin]); 359 | 360 | const res = await request(app) 361 | .get('/v1/users') 362 | .set('Authorization', `Bearer ${adminAccessToken}`) 363 | .query({ limit: 2 }) 364 | .send() 365 | .expect(httpStatus.OK); 366 | 367 | expect(res.body).toEqual({ 368 | results: expect.any(Array), 369 | page: 1, 370 | limit: 2, 371 | totalPages: 2, 372 | totalResults: 3, 373 | }); 374 | expect(res.body.results).toHaveLength(2); 375 | expect(res.body.results[0].id).toBe(userOne._id.toHexString()); 376 | expect(res.body.results[1].id).toBe(userTwo._id.toHexString()); 377 | }); 378 | 379 | test('should return the correct page if page and limit params are specified', async () => { 380 | await insertUsers([userOne, userTwo, admin]); 381 | 382 | const res = await request(app) 383 | .get('/v1/users') 384 | .set('Authorization', `Bearer ${adminAccessToken}`) 385 | .query({ page: 2, limit: 2 }) 386 | .send() 387 | .expect(httpStatus.OK); 388 | 389 | expect(res.body).toEqual({ 390 | results: expect.any(Array), 391 | page: 2, 392 | limit: 2, 393 | totalPages: 2, 394 | totalResults: 3, 395 | }); 396 | expect(res.body.results).toHaveLength(1); 397 | expect(res.body.results[0].id).toBe(admin._id.toHexString()); 398 | }); 399 | }); 400 | 401 | describe('GET /v1/users/:userId', () => { 402 | test('should return 200 and the user object if data is ok', async () => { 403 | await insertUsers([userOne]); 404 | 405 | const res = await request(app) 406 | .get(`/v1/users/${userOne._id}`) 407 | .set('Authorization', `Bearer ${userOneAccessToken}`) 408 | .send() 409 | .expect(httpStatus.OK); 410 | 411 | expect(res.body).not.toHaveProperty('password'); 412 | expect(res.body).toEqual({ 413 | id: userOne._id.toHexString(), 414 | email: userOne.email, 415 | name: userOne.name, 416 | role: userOne.role, 417 | isEmailVerified: userOne.isEmailVerified, 418 | }); 419 | }); 420 | 421 | test('should return 401 error if access token is missing', async () => { 422 | await insertUsers([userOne]); 423 | 424 | await request(app).get(`/v1/users/${userOne._id}`).send().expect(httpStatus.UNAUTHORIZED); 425 | }); 426 | 427 | test('should return 403 error if user is trying to get another user', async () => { 428 | await insertUsers([userOne, userTwo]); 429 | 430 | await request(app) 431 | .get(`/v1/users/${userTwo._id}`) 432 | .set('Authorization', `Bearer ${userOneAccessToken}`) 433 | .send() 434 | .expect(httpStatus.FORBIDDEN); 435 | }); 436 | 437 | test('should return 200 and the user object if admin is trying to get another user', async () => { 438 | await insertUsers([userOne, admin]); 439 | 440 | await request(app) 441 | .get(`/v1/users/${userOne._id}`) 442 | .set('Authorization', `Bearer ${adminAccessToken}`) 443 | .send() 444 | .expect(httpStatus.OK); 445 | }); 446 | 447 | test('should return 400 error if userId is not a valid mongo id', async () => { 448 | await insertUsers([admin]); 449 | 450 | await request(app) 451 | .get('/v1/users/invalidId') 452 | .set('Authorization', `Bearer ${adminAccessToken}`) 453 | .send() 454 | .expect(httpStatus.BAD_REQUEST); 455 | }); 456 | 457 | test('should return 404 error if user is not found', async () => { 458 | await insertUsers([admin]); 459 | 460 | await request(app) 461 | .get(`/v1/users/${userOne._id}`) 462 | .set('Authorization', `Bearer ${adminAccessToken}`) 463 | .send() 464 | .expect(httpStatus.NOT_FOUND); 465 | }); 466 | }); 467 | 468 | describe('DELETE /v1/users/:userId', () => { 469 | test('should return 204 if data is ok', async () => { 470 | await insertUsers([userOne]); 471 | 472 | await request(app) 473 | .delete(`/v1/users/${userOne._id}`) 474 | .set('Authorization', `Bearer ${userOneAccessToken}`) 475 | .send() 476 | .expect(httpStatus.NO_CONTENT); 477 | 478 | const dbUser = await User.findById(userOne._id); 479 | expect(dbUser).toBeNull(); 480 | }); 481 | 482 | test('should return 401 error if access token is missing', async () => { 483 | await insertUsers([userOne]); 484 | 485 | await request(app).delete(`/v1/users/${userOne._id}`).send().expect(httpStatus.UNAUTHORIZED); 486 | }); 487 | 488 | test('should return 403 error if user is trying to delete another user', async () => { 489 | await insertUsers([userOne, userTwo]); 490 | 491 | await request(app) 492 | .delete(`/v1/users/${userTwo._id}`) 493 | .set('Authorization', `Bearer ${userOneAccessToken}`) 494 | .send() 495 | .expect(httpStatus.FORBIDDEN); 496 | }); 497 | 498 | test('should return 204 if admin is trying to delete another user', async () => { 499 | await insertUsers([userOne, admin]); 500 | 501 | await request(app) 502 | .delete(`/v1/users/${userOne._id}`) 503 | .set('Authorization', `Bearer ${adminAccessToken}`) 504 | .send() 505 | .expect(httpStatus.NO_CONTENT); 506 | }); 507 | 508 | test('should return 400 error if userId is not a valid mongo id', async () => { 509 | await insertUsers([admin]); 510 | 511 | await request(app) 512 | .delete('/v1/users/invalidId') 513 | .set('Authorization', `Bearer ${adminAccessToken}`) 514 | .send() 515 | .expect(httpStatus.BAD_REQUEST); 516 | }); 517 | 518 | test('should return 404 error if user already is not found', async () => { 519 | await insertUsers([admin]); 520 | 521 | await request(app) 522 | .delete(`/v1/users/${userOne._id}`) 523 | .set('Authorization', `Bearer ${adminAccessToken}`) 524 | .send() 525 | .expect(httpStatus.NOT_FOUND); 526 | }); 527 | }); 528 | 529 | describe('PATCH /v1/users/:userId', () => { 530 | test('should return 200 and successfully update user if data is ok', async () => { 531 | await insertUsers([userOne]); 532 | const updateBody = { 533 | name: faker.name.findName(), 534 | email: faker.internet.email().toLowerCase(), 535 | password: 'newPassword1', 536 | }; 537 | 538 | const res = await request(app) 539 | .patch(`/v1/users/${userOne._id}`) 540 | .set('Authorization', `Bearer ${userOneAccessToken}`) 541 | .send(updateBody) 542 | .expect(httpStatus.OK); 543 | 544 | expect(res.body).not.toHaveProperty('password'); 545 | expect(res.body).toEqual({ 546 | id: userOne._id.toHexString(), 547 | name: updateBody.name, 548 | email: updateBody.email, 549 | role: 'user', 550 | isEmailVerified: false, 551 | }); 552 | 553 | const dbUser = await User.findById(userOne._id); 554 | expect(dbUser).toBeDefined(); 555 | if (!dbUser) return; 556 | expect(dbUser.password).not.toBe(updateBody.password); 557 | expect(dbUser).toMatchObject({ name: updateBody.name, email: updateBody.email, role: 'user' }); 558 | }); 559 | 560 | test('should return 401 error if access token is missing', async () => { 561 | await insertUsers([userOne]); 562 | const updateBody = { name: faker.name.findName() }; 563 | 564 | await request(app).patch(`/v1/users/${userOne._id}`).send(updateBody).expect(httpStatus.UNAUTHORIZED); 565 | }); 566 | 567 | test('should return 403 if user is updating another user', async () => { 568 | await insertUsers([userOne, userTwo]); 569 | const updateBody = { name: faker.name.findName() }; 570 | 571 | await request(app) 572 | .patch(`/v1/users/${userTwo._id}`) 573 | .set('Authorization', `Bearer ${userOneAccessToken}`) 574 | .send(updateBody) 575 | .expect(httpStatus.FORBIDDEN); 576 | }); 577 | 578 | test('should return 200 and successfully update user if admin is updating another user', async () => { 579 | await insertUsers([userOne, admin]); 580 | const updateBody = { name: faker.name.findName() }; 581 | 582 | await request(app) 583 | .patch(`/v1/users/${userOne._id}`) 584 | .set('Authorization', `Bearer ${adminAccessToken}`) 585 | .send(updateBody) 586 | .expect(httpStatus.OK); 587 | }); 588 | 589 | test('should return 404 if admin is updating another user that is not found', async () => { 590 | await insertUsers([admin]); 591 | const updateBody = { name: faker.name.findName() }; 592 | 593 | await request(app) 594 | .patch(`/v1/users/${userOne._id}`) 595 | .set('Authorization', `Bearer ${adminAccessToken}`) 596 | .send(updateBody) 597 | .expect(httpStatus.NOT_FOUND); 598 | }); 599 | 600 | test('should return 400 error if userId is not a valid mongo id', async () => { 601 | await insertUsers([admin]); 602 | const updateBody = { name: faker.name.findName() }; 603 | 604 | await request(app) 605 | .patch(`/v1/users/invalidId`) 606 | .set('Authorization', `Bearer ${adminAccessToken}`) 607 | .send(updateBody) 608 | .expect(httpStatus.BAD_REQUEST); 609 | }); 610 | 611 | test('should return 400 if email is invalid', async () => { 612 | await insertUsers([userOne]); 613 | const updateBody = { email: 'invalidEmail' }; 614 | 615 | await request(app) 616 | .patch(`/v1/users/${userOne._id}`) 617 | .set('Authorization', `Bearer ${userOneAccessToken}`) 618 | .send(updateBody) 619 | .expect(httpStatus.BAD_REQUEST); 620 | }); 621 | 622 | test('should return 400 if email is already taken', async () => { 623 | await insertUsers([userOne, userTwo]); 624 | const updateBody = { email: userTwo.email }; 625 | 626 | await request(app) 627 | .patch(`/v1/users/${userOne._id}`) 628 | .set('Authorization', `Bearer ${userOneAccessToken}`) 629 | .send(updateBody) 630 | .expect(httpStatus.BAD_REQUEST); 631 | }); 632 | 633 | test('should not return 400 if email is my email', async () => { 634 | await insertUsers([userOne]); 635 | const updateBody = { email: userOne.email }; 636 | 637 | await request(app) 638 | .patch(`/v1/users/${userOne._id}`) 639 | .set('Authorization', `Bearer ${userOneAccessToken}`) 640 | .send(updateBody) 641 | .expect(httpStatus.OK); 642 | }); 643 | 644 | test('should return 400 if password length is less than 8 characters', async () => { 645 | await insertUsers([userOne]); 646 | const updateBody = { password: 'passwo1' }; 647 | 648 | await request(app) 649 | .patch(`/v1/users/${userOne._id}`) 650 | .set('Authorization', `Bearer ${userOneAccessToken}`) 651 | .send(updateBody) 652 | .expect(httpStatus.BAD_REQUEST); 653 | }); 654 | 655 | test('should return 400 if password does not contain both letters and numbers', async () => { 656 | await insertUsers([userOne]); 657 | const updateBody = { password: 'password' }; 658 | 659 | await request(app) 660 | .patch(`/v1/users/${userOne._id}`) 661 | .set('Authorization', `Bearer ${userOneAccessToken}`) 662 | .send(updateBody) 663 | .expect(httpStatus.BAD_REQUEST); 664 | 665 | updateBody.password = '11111111'; 666 | 667 | await request(app) 668 | .patch(`/v1/users/${userOne._id}`) 669 | .set('Authorization', `Bearer ${userOneAccessToken}`) 670 | .send(updateBody) 671 | .expect(httpStatus.BAD_REQUEST); 672 | }); 673 | }); 674 | }); 675 | -------------------------------------------------------------------------------- /src/modules/user/user.validation.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | import { password, objectId } from '../validate/custom.validation'; 3 | import { NewCreatedUser } from './user.interfaces'; 4 | 5 | const createUserBody: Record = { 6 | email: Joi.string().required().email(), 7 | password: Joi.string().required().custom(password), 8 | name: Joi.string().required(), 9 | role: Joi.string().required().valid('user', 'admin'), 10 | }; 11 | 12 | export const createUser = { 13 | body: Joi.object().keys(createUserBody), 14 | }; 15 | 16 | export const getUsers = { 17 | query: Joi.object().keys({ 18 | name: Joi.string(), 19 | role: Joi.string(), 20 | sortBy: Joi.string(), 21 | projectBy: Joi.string(), 22 | limit: Joi.number().integer(), 23 | page: Joi.number().integer(), 24 | }), 25 | }; 26 | 27 | export const getUser = { 28 | params: Joi.object().keys({ 29 | userId: Joi.string().custom(objectId), 30 | }), 31 | }; 32 | 33 | export const updateUser = { 34 | params: Joi.object().keys({ 35 | userId: Joi.required().custom(objectId), 36 | }), 37 | body: Joi.object() 38 | .keys({ 39 | email: Joi.string().email(), 40 | password: Joi.string().custom(password), 41 | name: Joi.string(), 42 | }) 43 | .min(1), 44 | }; 45 | 46 | export const deleteUser = { 47 | params: Joi.object().keys({ 48 | userId: Joi.string().custom(objectId), 49 | }), 50 | }; 51 | -------------------------------------------------------------------------------- /src/modules/utils/catchAsync.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | 3 | const catchAsync = (fn: any) => (req: Request, res: Response, next: NextFunction) => { 4 | Promise.resolve(fn(req, res, next)).catch((err) => next(err)); 5 | }; 6 | 7 | export default catchAsync; 8 | -------------------------------------------------------------------------------- /src/modules/utils/index.ts: -------------------------------------------------------------------------------- 1 | import catchAsync from './catchAsync'; 2 | import pick from './pick'; 3 | import authLimiter from './rateLimiter'; 4 | 5 | export { catchAsync, pick, authLimiter }; 6 | -------------------------------------------------------------------------------- /src/modules/utils/pick.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Create an object composed of the picked object properties 3 | * @param {Record} object 4 | * @param {string[]} keys 5 | * @returns {Object} 6 | */ 7 | const pick = (object: Record, keys: string[]) => 8 | keys.reduce((obj: any, key: string) => { 9 | if (object && Object.prototype.hasOwnProperty.call(object, key)) { 10 | // eslint-disable-next-line no-param-reassign 11 | obj[key] = object[key]; 12 | } 13 | return obj; 14 | }, {}); 15 | 16 | export default pick; 17 | -------------------------------------------------------------------------------- /src/modules/utils/rateLimiter.ts: -------------------------------------------------------------------------------- 1 | import rateLimit from 'express-rate-limit'; 2 | 3 | const authLimiter = rateLimit({ 4 | windowMs: 15 * 60 * 1000, 5 | max: 20, 6 | skipSuccessfulRequests: true, 7 | }); 8 | 9 | export default authLimiter; 10 | -------------------------------------------------------------------------------- /src/modules/validate/custom.validation.ts: -------------------------------------------------------------------------------- 1 | import { CustomHelpers } from 'joi'; 2 | 3 | export const objectId = (value: string, helpers: CustomHelpers) => { 4 | if (!value.match(/^[0-9a-fA-F]{24}$/)) { 5 | return helpers.message({ custom: '"{{#label}}" must be a valid mongo id' }); 6 | } 7 | return value; 8 | }; 9 | 10 | export const password = (value: string, helpers: CustomHelpers) => { 11 | if (value.length < 8) { 12 | return helpers.message({ custom: 'password must be at least 8 characters' }); 13 | } 14 | if (!value.match(/\d/) || !value.match(/[a-zA-Z]/)) { 15 | return helpers.message({ custom: 'password must contain at least 1 letter and 1 number' }); 16 | } 17 | return value; 18 | }; 19 | -------------------------------------------------------------------------------- /src/modules/validate/index.ts: -------------------------------------------------------------------------------- 1 | import { objectId, password } from './custom.validation'; 2 | import validate from './validate.middleware'; 3 | 4 | export { objectId, password, validate }; 5 | -------------------------------------------------------------------------------- /src/modules/validate/validate.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import Joi from 'joi'; 3 | import httpStatus from 'http-status'; 4 | import pick from '../utils/pick'; 5 | import ApiError from '../errors/ApiError'; 6 | 7 | const validate = 8 | (schema: Record) => 9 | (req: Request, _res: Response, next: NextFunction): void => { 10 | const validSchema = pick(schema, ['params', 'query', 'body']); 11 | const object = pick(req, Object.keys(validSchema)); 12 | const { value, error } = Joi.compile(validSchema) 13 | .prefs({ errors: { label: 'key' } }) 14 | .validate(object); 15 | 16 | if (error) { 17 | const errorMessage = error.details.map((details) => details.message).join(', '); 18 | return next(new ApiError(httpStatus.BAD_REQUEST, errorMessage)); 19 | } 20 | Object.assign(req, value); 21 | return next(); 22 | }; 23 | 24 | export default validate; 25 | -------------------------------------------------------------------------------- /src/routes/v1/auth.route.ts: -------------------------------------------------------------------------------- 1 | import express, { Router } from 'express'; 2 | import { validate } from '../../modules/validate'; 3 | import { authValidation, authController, auth } from '../../modules/auth'; 4 | 5 | const router: Router = express.Router(); 6 | 7 | router.post('/register', validate(authValidation.register), authController.register); 8 | router.post('/login', validate(authValidation.login), authController.login); 9 | router.post('/logout', validate(authValidation.logout), authController.logout); 10 | router.post('/refresh-tokens', validate(authValidation.refreshTokens), authController.refreshTokens); 11 | router.post('/forgot-password', validate(authValidation.forgotPassword), authController.forgotPassword); 12 | router.post('/reset-password', validate(authValidation.resetPassword), authController.resetPassword); 13 | router.post('/send-verification-email', auth(), authController.sendVerificationEmail); 14 | router.post('/verify-email', validate(authValidation.verifyEmail), authController.verifyEmail); 15 | 16 | export default router; 17 | 18 | /** 19 | * @swagger 20 | * tags: 21 | * name: Auth 22 | * description: Authentication 23 | */ 24 | 25 | /** 26 | * @swagger 27 | * /auth/register: 28 | * post: 29 | * summary: Register as user 30 | * tags: [Auth] 31 | * requestBody: 32 | * required: true 33 | * content: 34 | * application/json: 35 | * schema: 36 | * type: object 37 | * required: 38 | * - name 39 | * - email 40 | * - password 41 | * properties: 42 | * name: 43 | * type: string 44 | * email: 45 | * type: string 46 | * format: email 47 | * description: must be unique 48 | * password: 49 | * type: string 50 | * format: password 51 | * minLength: 8 52 | * description: At least one number and one letter 53 | * example: 54 | * name: fake name 55 | * email: fake@example.com 56 | * password: password1 57 | * responses: 58 | * "201": 59 | * description: Created 60 | * content: 61 | * application/json: 62 | * schema: 63 | * type: object 64 | * properties: 65 | * user: 66 | * $ref: '#/components/schemas/User' 67 | * tokens: 68 | * $ref: '#/components/schemas/AuthTokens' 69 | * "400": 70 | * $ref: '#/components/responses/DuplicateEmail' 71 | */ 72 | 73 | /** 74 | * @swagger 75 | * /auth/login: 76 | * post: 77 | * summary: Login 78 | * tags: [Auth] 79 | * requestBody: 80 | * required: true 81 | * content: 82 | * application/json: 83 | * schema: 84 | * type: object 85 | * required: 86 | * - email 87 | * - password 88 | * properties: 89 | * email: 90 | * type: string 91 | * format: email 92 | * password: 93 | * type: string 94 | * format: password 95 | * example: 96 | * email: fake@example.com 97 | * password: password1 98 | * responses: 99 | * "200": 100 | * description: OK 101 | * content: 102 | * application/json: 103 | * schema: 104 | * type: object 105 | * properties: 106 | * user: 107 | * $ref: '#/components/schemas/User' 108 | * tokens: 109 | * $ref: '#/components/schemas/AuthTokens' 110 | * "401": 111 | * description: Invalid email or password 112 | * content: 113 | * application/json: 114 | * schema: 115 | * $ref: '#/components/schemas/Error' 116 | * example: 117 | * code: 401 118 | * message: Invalid email or password 119 | */ 120 | 121 | /** 122 | * @swagger 123 | * /auth/logout: 124 | * post: 125 | * summary: Logout 126 | * tags: [Auth] 127 | * requestBody: 128 | * required: true 129 | * content: 130 | * application/json: 131 | * schema: 132 | * type: object 133 | * required: 134 | * - refreshToken 135 | * properties: 136 | * refreshToken: 137 | * type: string 138 | * example: 139 | * refreshToken: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1ZWJhYzUzNDk1NGI1NDEzOTgwNmMxMTIiLCJpYXQiOjE1ODkyOTg0ODQsImV4cCI6MTU4OTMwMDI4NH0.m1U63blB0MLej_WfB7yC2FTMnCziif9X8yzwDEfJXAg 140 | * responses: 141 | * "204": 142 | * description: No content 143 | * "404": 144 | * $ref: '#/components/responses/NotFound' 145 | */ 146 | 147 | /** 148 | * @swagger 149 | * /auth/refresh-tokens: 150 | * post: 151 | * summary: Refresh auth tokens 152 | * tags: [Auth] 153 | * requestBody: 154 | * required: true 155 | * content: 156 | * application/json: 157 | * schema: 158 | * type: object 159 | * required: 160 | * - refreshToken 161 | * properties: 162 | * refreshToken: 163 | * type: string 164 | * example: 165 | * refreshToken: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1ZWJhYzUzNDk1NGI1NDEzOTgwNmMxMTIiLCJpYXQiOjE1ODkyOTg0ODQsImV4cCI6MTU4OTMwMDI4NH0.m1U63blB0MLej_WfB7yC2FTMnCziif9X8yzwDEfJXAg 166 | * responses: 167 | * "200": 168 | * description: OK 169 | * content: 170 | * application/json: 171 | * schema: 172 | * $ref: '#/components/schemas/UserWithTokens' 173 | * "401": 174 | * $ref: '#/components/responses/Unauthorized' 175 | */ 176 | 177 | /** 178 | * @swagger 179 | * /auth/forgot-password: 180 | * post: 181 | * summary: Forgot password 182 | * description: An email will be sent to reset password. 183 | * tags: [Auth] 184 | * requestBody: 185 | * required: true 186 | * content: 187 | * application/json: 188 | * schema: 189 | * type: object 190 | * required: 191 | * - email 192 | * properties: 193 | * email: 194 | * type: string 195 | * format: email 196 | * example: 197 | * email: fake@example.com 198 | * responses: 199 | * "204": 200 | * description: No content 201 | * "404": 202 | * $ref: '#/components/responses/NotFound' 203 | */ 204 | 205 | /** 206 | * @swagger 207 | * /auth/reset-password: 208 | * post: 209 | * summary: Reset password 210 | * tags: [Auth] 211 | * parameters: 212 | * - in: query 213 | * name: token 214 | * required: true 215 | * schema: 216 | * type: string 217 | * description: The reset password token 218 | * requestBody: 219 | * required: true 220 | * content: 221 | * application/json: 222 | * schema: 223 | * type: object 224 | * required: 225 | * - password 226 | * properties: 227 | * password: 228 | * type: string 229 | * format: password 230 | * minLength: 8 231 | * description: At least one number and one letter 232 | * example: 233 | * password: password1 234 | * responses: 235 | * "204": 236 | * description: No content 237 | * "401": 238 | * description: Password reset failed 239 | * content: 240 | * application/json: 241 | * schema: 242 | * $ref: '#/components/schemas/Error' 243 | * example: 244 | * code: 401 245 | * message: Password reset failed 246 | */ 247 | 248 | /** 249 | * @swagger 250 | * /auth/send-verification-email: 251 | * post: 252 | * summary: Send verification email 253 | * description: An email will be sent to verify email. 254 | * tags: [Auth] 255 | * security: 256 | * - bearerAuth: [] 257 | * responses: 258 | * "204": 259 | * description: No content 260 | * "401": 261 | * $ref: '#/components/responses/Unauthorized' 262 | */ 263 | 264 | /** 265 | * @swagger 266 | * /auth/verify-email: 267 | * post: 268 | * summary: verify email 269 | * tags: [Auth] 270 | * parameters: 271 | * - in: query 272 | * name: token 273 | * required: true 274 | * schema: 275 | * type: string 276 | * description: The verify email token 277 | * responses: 278 | * "204": 279 | * description: No content 280 | * "401": 281 | * description: verify email failed 282 | * content: 283 | * application/json: 284 | * schema: 285 | * $ref: '#/components/schemas/Error' 286 | * example: 287 | * code: 401 288 | * message: verify email failed 289 | */ 290 | -------------------------------------------------------------------------------- /src/routes/v1/index.ts: -------------------------------------------------------------------------------- 1 | import express, { Router } from 'express'; 2 | import authRoute from './auth.route'; 3 | import docsRoute from './swagger.route'; 4 | import userRoute from './user.route'; 5 | import config from '../../config/config'; 6 | 7 | const router = express.Router(); 8 | 9 | interface IRoute { 10 | path: string; 11 | route: Router; 12 | } 13 | 14 | const defaultIRoute: IRoute[] = [ 15 | { 16 | path: '/auth', 17 | route: authRoute, 18 | }, 19 | { 20 | path: '/users', 21 | route: userRoute, 22 | }, 23 | ]; 24 | 25 | const devIRoute: IRoute[] = [ 26 | // IRoute available only in development mode 27 | { 28 | path: '/docs', 29 | route: docsRoute, 30 | }, 31 | ]; 32 | 33 | defaultIRoute.forEach((route) => { 34 | router.use(route.path, route.route); 35 | }); 36 | 37 | /* istanbul ignore next */ 38 | if (config.env === 'development') { 39 | devIRoute.forEach((route) => { 40 | router.use(route.path, route.route); 41 | }); 42 | } 43 | 44 | export default router; 45 | -------------------------------------------------------------------------------- /src/routes/v1/swagger.route.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import swaggerJsdoc from 'swagger-jsdoc'; 3 | import swaggerUi from 'swagger-ui-express'; 4 | import swaggerDefinition from '../../modules/swagger/swagger.definition'; 5 | 6 | const router = express.Router(); 7 | 8 | const specs = swaggerJsdoc({ 9 | swaggerDefinition, 10 | apis: ['packages/components.yaml', 'dist/routes/v1/*.js'], 11 | }); 12 | 13 | router.use('/', swaggerUi.serve); 14 | router.get( 15 | '/', 16 | swaggerUi.setup(specs, { 17 | explorer: true, 18 | }) 19 | ); 20 | 21 | export default router; 22 | -------------------------------------------------------------------------------- /src/routes/v1/user.route.ts: -------------------------------------------------------------------------------- 1 | import express, { Router } from 'express'; 2 | import { validate } from '../../modules/validate'; 3 | import { auth } from '../../modules/auth'; 4 | import { userController, userValidation } from '../../modules/user'; 5 | 6 | const router: Router = express.Router(); 7 | 8 | router 9 | .route('/') 10 | .post(auth('manageUsers'), validate(userValidation.createUser), userController.createUser) 11 | .get(auth('getUsers'), validate(userValidation.getUsers), userController.getUsers); 12 | 13 | router 14 | .route('/:userId') 15 | .get(auth('getUsers'), validate(userValidation.getUser), userController.getUser) 16 | .patch(auth('manageUsers'), validate(userValidation.updateUser), userController.updateUser) 17 | .delete(auth('manageUsers'), validate(userValidation.deleteUser), userController.deleteUser); 18 | 19 | export default router; 20 | 21 | /** 22 | * @swagger 23 | * tags: 24 | * name: Users 25 | * description: User management and retrieval 26 | */ 27 | 28 | /** 29 | * @swagger 30 | * /users: 31 | * post: 32 | * summary: Create a user 33 | * description: Only admins can create other users. 34 | * tags: [Users] 35 | * security: 36 | * - bearerAuth: [] 37 | * requestBody: 38 | * required: true 39 | * content: 40 | * application/json: 41 | * schema: 42 | * type: object 43 | * required: 44 | * - name 45 | * - email 46 | * - password 47 | * - role 48 | * properties: 49 | * name: 50 | * type: string 51 | * email: 52 | * type: string 53 | * format: email 54 | * description: must be unique 55 | * password: 56 | * type: string 57 | * format: password 58 | * minLength: 8 59 | * description: At least one number and one letter 60 | * role: 61 | * type: string 62 | * enum: [user, admin] 63 | * example: 64 | * name: fake name 65 | * email: fake@example.com 66 | * password: password1 67 | * role: user 68 | * responses: 69 | * "201": 70 | * description: Created 71 | * content: 72 | * application/json: 73 | * schema: 74 | * $ref: '#/components/schemas/User' 75 | * "400": 76 | * $ref: '#/components/responses/DuplicateEmail' 77 | * "401": 78 | * $ref: '#/components/responses/Unauthorized' 79 | * "403": 80 | * $ref: '#/components/responses/Forbidden' 81 | * 82 | * get: 83 | * summary: Get all users 84 | * description: Only admins can retrieve all users. 85 | * tags: [Users] 86 | * security: 87 | * - bearerAuth: [] 88 | * parameters: 89 | * - in: query 90 | * name: name 91 | * schema: 92 | * type: string 93 | * description: User name 94 | * - in: query 95 | * name: role 96 | * schema: 97 | * type: string 98 | * description: User role 99 | * - in: query 100 | * name: sortBy 101 | * schema: 102 | * type: string 103 | * description: sort by query in the form of field:desc/asc (ex. name:asc) 104 | * - in: query 105 | * name: projectBy 106 | * schema: 107 | * type: string 108 | * description: project by query in the form of field:hide/include (ex. name:hide) 109 | * - in: query 110 | * name: limit 111 | * schema: 112 | * type: integer 113 | * minimum: 1 114 | * default: 10 115 | * description: Maximum number of users 116 | * - in: query 117 | * name: page 118 | * schema: 119 | * type: integer 120 | * minimum: 1 121 | * default: 1 122 | * description: Page number 123 | * responses: 124 | * "200": 125 | * description: OK 126 | * content: 127 | * application/json: 128 | * schema: 129 | * type: object 130 | * properties: 131 | * results: 132 | * type: array 133 | * items: 134 | * $ref: '#/components/schemas/User' 135 | * page: 136 | * type: integer 137 | * example: 1 138 | * limit: 139 | * type: integer 140 | * example: 10 141 | * totalPages: 142 | * type: integer 143 | * example: 1 144 | * totalResults: 145 | * type: integer 146 | * example: 1 147 | * "401": 148 | * $ref: '#/components/responses/Unauthorized' 149 | * "403": 150 | * $ref: '#/components/responses/Forbidden' 151 | */ 152 | 153 | /** 154 | * @swagger 155 | * /users/{id}: 156 | * get: 157 | * summary: Get a user 158 | * description: Logged in users can fetch only their own user information. Only admins can fetch other users. 159 | * tags: [Users] 160 | * security: 161 | * - bearerAuth: [] 162 | * parameters: 163 | * - in: path 164 | * name: id 165 | * required: true 166 | * schema: 167 | * type: string 168 | * description: User id 169 | * responses: 170 | * "200": 171 | * description: OK 172 | * content: 173 | * application/json: 174 | * schema: 175 | * $ref: '#/components/schemas/User' 176 | * "401": 177 | * $ref: '#/components/responses/Unauthorized' 178 | * "403": 179 | * $ref: '#/components/responses/Forbidden' 180 | * "404": 181 | * $ref: '#/components/responses/NotFound' 182 | * 183 | * patch: 184 | * summary: Update a user 185 | * description: Logged in users can only update their own information. Only admins can update other users. 186 | * tags: [Users] 187 | * security: 188 | * - bearerAuth: [] 189 | * parameters: 190 | * - in: path 191 | * name: id 192 | * required: true 193 | * schema: 194 | * type: string 195 | * description: User id 196 | * requestBody: 197 | * required: true 198 | * content: 199 | * application/json: 200 | * schema: 201 | * type: object 202 | * properties: 203 | * name: 204 | * type: string 205 | * email: 206 | * type: string 207 | * format: email 208 | * description: must be unique 209 | * password: 210 | * type: string 211 | * format: password 212 | * minLength: 8 213 | * description: At least one number and one letter 214 | * example: 215 | * name: fake name 216 | * email: fake@example.com 217 | * password: password1 218 | * responses: 219 | * "200": 220 | * description: OK 221 | * content: 222 | * application/json: 223 | * schema: 224 | * $ref: '#/components/schemas/User' 225 | * "400": 226 | * $ref: '#/components/responses/DuplicateEmail' 227 | * "401": 228 | * $ref: '#/components/responses/Unauthorized' 229 | * "403": 230 | * $ref: '#/components/responses/Forbidden' 231 | * "404": 232 | * $ref: '#/components/responses/NotFound' 233 | * 234 | * delete: 235 | * summary: Delete a user 236 | * description: Logged in users can delete only themselves. Only admins can delete other users. 237 | * tags: [Users] 238 | * security: 239 | * - bearerAuth: [] 240 | * parameters: 241 | * - in: path 242 | * name: id 243 | * required: true 244 | * schema: 245 | * type: string 246 | * description: User id 247 | * responses: 248 | * "200": 249 | * description: No content 250 | * "401": 251 | * $ref: '#/components/responses/Unauthorized' 252 | * "403": 253 | * $ref: '#/components/responses/Forbidden' 254 | * "404": 255 | * $ref: '#/components/responses/NotFound' 256 | */ 257 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2017", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "es6", /* Specify what module code is generated. */ 28 | "rootDir": "./src", /* Specify the root folder within your source files. */ 29 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | "paths": { 32 | "@/modules/*": [ 33 | "src/modules/*" 34 | ], 35 | }, /* Specify a set of entries that re-map imports to additional lookup locations. */ 36 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 37 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 38 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 39 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 40 | "resolveJsonModule": true, /* Enable importing .json files */ 41 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 42 | 43 | /* JavaScript Support */ 44 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 45 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 46 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 47 | 48 | /* Emit */ 49 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 50 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 51 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 52 | "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 53 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 54 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 55 | "removeComments": false, /* Disable emitting comments. */ 56 | // "noEmit": true, /* Disable emitting files from a compilation. */ 57 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 58 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 59 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 60 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 61 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 62 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 63 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 64 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 65 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 66 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 67 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 68 | "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 69 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 70 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 71 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 72 | 73 | /* Interop Constraints */ 74 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 75 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 76 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 77 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 78 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 79 | 80 | /* Type Checking */ 81 | "strict": true, /* Enable all strict type-checking options. */ 82 | "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 83 | "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 84 | "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 85 | "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 86 | "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 87 | "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 88 | "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 89 | "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 90 | "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 91 | "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 92 | "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 93 | "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 94 | "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 95 | "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 96 | "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 97 | "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 98 | "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 99 | "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 100 | 101 | /* Completeness */ 102 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 103 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 104 | }, 105 | } 106 | -------------------------------------------------------------------------------- /tsconfig.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"program":{"fileNames":["./node_modules/typescript/lib/lib.es6.d.ts","./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.webworker.importscripts.d.ts","./node_modules/typescript/lib/lib.scripthost.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./package.json","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@types/bcryptjs/index.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/globals.global.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/connect/index.d.ts","./node_modules/@types/body-parser/index.d.ts","./node_modules/@types/range-parser/index.d.ts","./node_modules/@types/qs/index.d.ts","./node_modules/@types/express-serve-static-core/index.d.ts","./node_modules/@types/mime/index.d.ts","./node_modules/@types/serve-static/index.d.ts","./node_modules/@types/express/index.d.ts","./node_modules/@types/compression/index.d.ts","./node_modules/@types/cookie-parser/index.d.ts","./node_modules/@types/cors/index.d.ts","./node_modules/@types/express-rate-limit/index.d.ts","./node_modules/@types/graceful-fs/index.d.ts","./node_modules/@types/istanbul-lib-coverage/index.d.ts","./node_modules/@types/istanbul-lib-report/index.d.ts","./node_modules/@types/istanbul-reports/index.d.ts","./node_modules/@types/json-schema/index.d.ts","./node_modules/@types/json5/index.d.ts","./node_modules/@types/jsonwebtoken/index.d.ts","./node_modules/@types/morgan/index.d.ts","./node_modules/@types/nodemailer/lib/dkim/index.d.ts","./node_modules/@types/nodemailer/lib/mailer/mail-message.d.ts","./node_modules/@types/nodemailer/lib/xoauth2.d.ts","./node_modules/@types/nodemailer/lib/mailer/index.d.ts","./node_modules/@types/nodemailer/lib/mime-node/index.d.ts","./node_modules/@types/nodemailer/lib/smtp-connection/index.d.ts","./node_modules/@types/nodemailer/lib/shared.d.ts","./node_modules/@types/nodemailer/lib/json-transport.d.ts","./node_modules/@types/nodemailer/lib/sendmail-transport/index.d.ts","./node_modules/@types/nodemailer/lib/ses-transport.d.ts","./node_modules/@types/nodemailer/lib/smtp-pool/index.d.ts","./node_modules/@types/nodemailer/lib/smtp-transport.d.ts","./node_modules/@types/nodemailer/lib/stream-transport.d.ts","./node_modules/@types/nodemailer/index.d.ts","./node_modules/@types/passport/index.d.ts","./node_modules/@types/passport-strategy/index.d.ts","./node_modules/@types/passport-jwt/index.d.ts","./node_modules/@types/prettier/index.d.ts","./node_modules/@types/stack-utils/index.d.ts","./node_modules/@types/swagger-jsdoc/index.d.ts","./node_modules/@types/swagger-ui-express/index.d.ts","./node_modules/@types/validator/lib/isBoolean.d.ts","./node_modules/@types/validator/lib/isEmail.d.ts","./node_modules/@types/validator/lib/isFQDN.d.ts","./node_modules/@types/validator/lib/isIBAN.d.ts","./node_modules/@types/validator/lib/isISO4217.d.ts","./node_modules/@types/validator/lib/isURL.d.ts","./node_modules/@types/validator/index.d.ts","./node_modules/@types/webidl-conversions/index.d.ts","./node_modules/@types/whatwg-url/index.d.ts","./node_modules/@types/yargs-parser/index.d.ts","./node_modules/@types/yargs/index.d.ts"],"fileInfos":["721cec59c3fef87aaf480047d821fb758b3ec9482c4129a54631e6e25e432a31",{"version":"89f78430e422a0f06d13019d60d5a45b37ec2d28e67eb647f73b1b0d19a46b72","affectsGlobalScope":true},"dc47c4fa66b9b9890cf076304de2a9c5201e94b740cffdf09f87296d877d71f6","7a387c58583dfca701b6c85e0adaf43fb17d590fb16d5b2dc0a2fbd89f35c467","8a12173c586e95f4433e0c6dc446bc88346be73ffe9ca6eec7aa63c8f3dca7f9","5f4e733ced4e129482ae2186aae29fde948ab7182844c3a5a51dd346182c7b06","e6b724280c694a9f588847f754198fb96c43d805f065c3a5b28bbc9594541c84","e21c071ca3e1b4a815d5f04a7475adcaeea5d64367e840dd0154096d705c3940",{"version":"abba1071bfd89e55e88a054b0c851ea3e8a494c340d0f3fab19eb18f6afb0c9e","affectsGlobalScope":true},{"version":"927cb2b60048e1395b183bf74b2b80a75bdb1dbe384e1d9fac654313ea2fb136","affectsGlobalScope":true},{"version":"7fac8cb5fc820bc2a59ae11ef1c5b38d3832c6d0dfaec5acdb5569137d09a481","affectsGlobalScope":true},{"version":"097a57355ded99c68e6df1b738990448e0bf170e606707df5a7c0481ff2427cd","affectsGlobalScope":true},{"version":"d8996609230d17e90484a2dd58f22668f9a05a3bfe00bfb1d6271171e54a31fb","affectsGlobalScope":true},{"version":"43fb1d932e4966a39a41b464a12a81899d9ae5f2c829063f5571b6b87e6d2f9c","affectsGlobalScope":true},{"version":"cdccba9a388c2ee3fd6ad4018c640a471a6c060e96f1232062223063b0a5ac6a","affectsGlobalScope":true},{"version":"4378fc8122ec9d1a685b01eb66c46f62aba6b239ca7228bb6483bcf8259ee493","affectsGlobalScope":true},{"version":"0d5f52b3174bee6edb81260ebcd792692c32c81fd55499d69531496f3f2b25e7","affectsGlobalScope":true},{"version":"810627a82ac06fb5166da5ada4159c4ec11978dfbb0805fe804c86406dab8357","affectsGlobalScope":true},{"version":"62d80405c46c3f4c527ee657ae9d43fda65a0bf582292429aea1e69144a522a6","affectsGlobalScope":true},{"version":"3013574108c36fd3aaca79764002b3717da09725a36a6fc02eac386593110f93","affectsGlobalScope":true},{"version":"75ec0bdd727d887f1b79ed6619412ea72ba3c81d92d0787ccb64bab18d261f14","affectsGlobalScope":true},{"version":"3be5a1453daa63e031d266bf342f3943603873d890ab8b9ada95e22389389006","affectsGlobalScope":true},{"version":"17bb1fc99591b00515502d264fa55dc8370c45c5298f4a5c2083557dccba5a2a","affectsGlobalScope":true},{"version":"7ce9f0bde3307ca1f944119f6365f2d776d281a393b576a18a2f2893a2d75c98","affectsGlobalScope":true},{"version":"6a6b173e739a6a99629a8594bfb294cc7329bfb7b227f12e1f7c11bc163b8577","affectsGlobalScope":true},{"version":"12a310447c5d23c7d0d5ca2af606e3bd08afda69100166730ab92c62999ebb9d","affectsGlobalScope":true},{"version":"b0124885ef82641903d232172577f2ceb5d3e60aed4da1153bab4221e1f6dd4e","affectsGlobalScope":true},{"version":"0eb85d6c590b0d577919a79e0084fa1744c1beba6fd0d4e951432fa1ede5510a","affectsGlobalScope":true},{"version":"da233fc1c8a377ba9e0bed690a73c290d843c2c3d23a7bd7ec5cd3d7d73ba1e0","affectsGlobalScope":true},{"version":"d154ea5bb7f7f9001ed9153e876b2d5b8f5c2bb9ec02b3ae0d239ec769f1f2ae","affectsGlobalScope":true},{"version":"bb2d3fb05a1d2ffbca947cc7cbc95d23e1d053d6595391bd325deb265a18d36c","affectsGlobalScope":true},{"version":"c80df75850fea5caa2afe43b9949338ce4e2de086f91713e9af1a06f973872b8","affectsGlobalScope":true},{"version":"9d57b2b5d15838ed094aa9ff1299eecef40b190722eb619bac4616657a05f951","affectsGlobalScope":true},{"version":"6c51b5dd26a2c31dbf37f00cfc32b2aa6a92e19c995aefb5b97a3a64f1ac99de","affectsGlobalScope":true},{"version":"6e7997ef61de3132e4d4b2250e75343f487903ddf5370e7ce33cf1b9db9a63ed","affectsGlobalScope":true},{"version":"2ad234885a4240522efccd77de6c7d99eecf9b4de0914adb9a35c0c22433f993","affectsGlobalScope":true},{"version":"1b3fe904465430e030c93239a348f05e1be80640d91f2f004c3512c2c2c89f34","affectsGlobalScope":true},{"version":"3787b83e297de7c315d55d4a7c546ae28e5f6c0a361b7a1dcec1f1f50a54ef11","affectsGlobalScope":true},{"version":"e7e8e1d368290e9295ef18ca23f405cf40d5456fa9f20db6373a61ca45f75f40","affectsGlobalScope":true},{"version":"faf0221ae0465363c842ce6aa8a0cbda5d9296940a8e26c86e04cc4081eea21e","affectsGlobalScope":true},{"version":"06393d13ea207a1bfe08ec8d7be562549c5e2da8983f2ee074e00002629d1871","affectsGlobalScope":true},{"version":"d071129cba6a5f2700be09c86c07ad2791ab67d4e5ed1eb301d6746c62745ea4","affectsGlobalScope":true},{"version":"10bbdc1981b8d9310ee75bfac28ee0477bb2353e8529da8cff7cb26c409cb5e8","affectsGlobalScope":true},{"version":"84d48247d91a39d8c77ef845144ee5aec8ebf05376d345a28799af15c83d6bde","signature":"ba4a229e359898526a6870a91deb7d26e2d7427c80d5ee8f8796d6e20f7d2775"},"272c2dac4baaf7fdd2d7efeef0fa2547af54cc21883c5e138b8c4d1661697a54","8dfed5c91ad36e69e6da6b7e49be929d4e19666db2b651aa839c485170a2902c","64b867c61effed7b5bc0cc06b3d8eac23b067a3fba581fc7d3c292fa593e6a45","93de1c6dab503f053efe8d304cb522bb3a89feab8c98f307a674a4fae04773e9","3b043cf9a81854a72963fdb57d1884fc4da1cf5be69b5e0a4c5b751e58cb6d88","80164ffebe1723a50e020a648e0623c026ff39be13c5cd45e6a82d0fcc06e2d0","9dfe431ab1485e17a6055e186c49da9d23af74b965f2e99f8acc6c958778608f","0d5a2ee1fdfa82740e0103389b9efd6bfe145a20018a2da3c02b89666181f4d9","a69c09dbea52352f479d3e7ac949fde3d17b195abe90b045d619f747b38d6d1a",{"version":"92d63add669d18ebc349efbacd88966d6f2ccdddfb1b880b2db98ae3aa7bf7c4","affectsGlobalScope":true},"ccc94049a9841fe47abe5baef6be9a38fc6228807974ae675fb15dc22531b4be",{"version":"9acfe4d1ff027015151ce81d60797b04b52bffe97ad8310bb0ec2e8fd61e1303","affectsGlobalScope":true},"95843d5cfafced8f3f8a5ce57d2335f0bcd361b9483587d12a25e4bd403b8216","afc6e96061af46bcff47246158caee7e056f5288783f2d83d6858cd25be1c565",{"version":"34f5bcac12b36d70304b73de5f5aab3bb91bd9919f984be80579ebcad03a624e","affectsGlobalScope":true},"82408ed3e959ddc60d3e9904481b5a8dc16469928257af22a3f7d1a3bc7fd8c4","2f520601649a893e6a49a8851ebfcf4be8ce090dc1281c2a08a871cb04e8251f","f50c975ab7b50e25a69e3d8a3773894125b44e9698924105f23b812bf7488baf","2b8c764f856a1dd0a9a2bf23e5efddbff157de8138b0754010be561ae5fcaa90","76650408392bf49a8fbf3e2b6b302712a92d76af77b06e2da1cc8077359c4409","0af3121e68297b2247dd331c0d24dba599e50736a7517a5622d5591aae4a3122","6972fca26f6e9bd56197568d4379f99071a90766e06b4fcb5920a0130a9202be",{"version":"4a2628e95962c8ab756121faa3ac2ed348112ff7a87b5c286dd2cc3326546b4c","affectsGlobalScope":true},"80793b2277f31baa199234daed806fff0fb11491d1ebd3357e520c3558063f00","a049a59a02009fc023684fcfaf0ac526fe36c35dcc5d2b7d620c1750ba11b083","b9b963043551b034abd9e7c6d859f7a81d99479fde938d983114d167d0644a78","b287b810b5035d5685f1df6e1e418f1ca452a3ed4f59fd5cc081dbf2045f0d9b","4b9a003b5c556c96784132945bb41c655ea11273b1917f5c8d0c154dd5fd20dd","a458dc78104cc80048ac24fdc02fe6dce254838094c2f25641b3f954d9721241",{"version":"e8b18c6385ff784228a6f369694fcf1a6b475355ba89090a88de13587a9391d5","affectsGlobalScope":true},"902cd98bf46e95caf4118a0733fb801e9e90eec3edaed6abdad77124afec9ca2","abc1c425b2ad6720433f40f1877abfa4223f0f3dd486c9c28c492179ca183cb6","cd4854d38f4eb5592afd98ab95ca17389a7dfe38013d9079e802d739bdbcc939","94eed4cc2f5f658d5e229ff1ccd38860bddf4233e347bf78edd2154dee1f2b99",{"version":"bd1a08e30569b0fb2f0b21035eb9b039871f68faa9b98accf847e9c878c5e0a9","affectsGlobalScope":true},"9f1069b9e2c051737b1f9b4f1baf50e4a63385a6a89c32235549ae87fc3d5492","ee18f2da7a037c6ceeb112a084e485aead9ea166980bf433474559eac1b46553","29c2706fa0cc49a2bd90c83234da33d08bb9554ecec675e91c1f85087f5a5324","0acbf26bf958f9e80c1ffa587b74749d2697b75b484062d36e103c137c562bc3","d7838022c7dab596357a9604b9c6adffe37dc34085ce0779c958ce9545bd7139","1b952304137851e45bc009785de89ada562d9376177c97e37702e39e60c2f1ff",{"version":"806ef4cac3b3d9fa4a48d849c8e084d7c72fcd7b16d76e06049a9ed742ff79c0","affectsGlobalScope":true},"a7971f9fb2a32ec7788ec6cda9d7a33c02023dfe9a62db2030ad1359649d8050","c33a6ea7147af60d8e98f1ac127047f4b0d4e2ce28b8f08ff3de07ca7cc00637",{"version":"b42b47e17b8ece2424ae8039feb944c2e3ba4b262986aebd582e51efbdca93dc","affectsGlobalScope":true},"664d8f2d59164f2e08c543981453893bc7e003e4dfd29651ce09db13e9457980","2408611d9b4146e35d1dbd1f443ccd8e187c74614a54b80300728277529dbf11","998a3de5237518c0b3ac00a11b3b4417affb008aa20aedee52f3fdae3cb86151","ad41008ffe077206e1811fc873f4d9005b5fd7f6ab52bb6118fef600815a5cb4","d88ecca73348e7c337541c4b8b60a50aca5e87384f6b8a422fc6603c637e4c21","badae0df9a8016ac36994b0a0e7b82ba6aaa3528e175a8c3cb161e4683eec03e","c3db860bcaaaeb3bbc23f353bbda1f8ab82756c8d5e973bebb3953cb09ea68f2","235a53595bd20b0b0eeb1a29cb2887c67c48375e92f03749b2488fbd46d0b1a0","bc09393cd4cd13f69cf1366d4236fbae5359bb550f0de4e15767e9a91d63dfb1","9c266243b01545e11d2733a55ad02b4c00ecdbda99c561cd1674f96e89cdc958","c71155c05fc76ff948a4759abc1cb9feec036509f500174bc18dad4c7827a60c",{"version":"ab9b9a36e5284fd8d3bf2f7d5fcbc60052f25f27e4d20954782099282c60d23e","affectsGlobalScope":true},"1cdb8f094b969dcc183745dc88404e2d8fcf2a858c6e7cc2441011476573238e","6d829824ead8999f87b6df21200df3c6150391b894b4e80662caa462bd48d073","afc559c1b93df37c25aef6b3dfa2d64325b0e112e887ee18bf7e6f4ec383fc90","16d51f964ec125ad2024cf03f0af444b3bc3ec3614d9345cc54d09bab45c9a4c","ba601641fac98c229ccd4a303f747de376d761babb33229bb7153bed9356c9cc",{"version":"44f372b501e58c4a02bcdf4772d25a1239abd89339a19e7c25527aa0cbf37c32","affectsGlobalScope":true},"84e3bbd6f80983d468260fdbfeeb431cc81f7ea98d284d836e4d168e36875e86","0b85cb069d0e427ba946e5eb2d86ef65ffd19867042810516d16919f6c1a5aec","15c88bfd1b8dc7231ff828ae8df5d955bae5ebca4cf2bcb417af5821e52299ae",{"version":"717e5f864ab6e755a23b66a7935920d9a73ec7d4728b84ddf85c1d334a9414ae","affectsGlobalScope":true},"8d77ed4e39114c32edc2e35b683b6f54a6b3292ecdf392e4bfc5f726734955d8","7ba5854095e85c14e153f087b1232e19ed2fee62a7fefd8bf57e0956edab4ae2",{"version":"836b023fbf1dc8d701c493874487a5a6ac4ca8877f44cce40efc87aabb83f19a","affectsGlobalScope":true},"3ebae8c00411116a66fca65b08228ea0cf0b72724701f9b854442100aab55aba","de18acda71730bac52f4b256ce7511bb56cc21f6f114c59c46782eff2f632857","7eb06594824ada538b1d8b48c3925a83e7db792f47a081a62cf3e5c4e23cf0ee","f5638f7c2f12a9a1a57b5c41b3c1ea7db3876c003bab68e6a57afd6bcc169af0","0359682c54e487c4cab2b53b2b4d35cc8dea4d9914bc6abcdb5701f8b8e745a4","96d14f21b7652903852eef49379d04dbda28c16ed36468f8c9fa08f7c14c9538","5d9e5a0e7c5eb0fc848461a0834d0c4f39df17044c65bcc637ad3472f7cf5813","eed3474a76f30632cd88fbacc0f861faa6d8df35f7588add25df88d7f422825f","bb654d426b82e0846cd4bd7de91d637039ecdfd63c94447373490178f80846fe","db90f54098b237753ac9c846e39cd49aa538dcad07a2e1c68a138f3c0f8e621d","892f8cf5db77196b9f4da151423570a9ad92f01692740d6137c0a13a006d9121","6e41751646fe275206d052a2e5ba87d27dd0df4dcbca134fc3fd2dc4d3d410d2","eecb2ea10a1500dcc6bdeff14be1fb43806f63a9b8562e16e1b4fc8baa8dfa8d","221a6ab66d611349faaf80af49c7a34d95623787610fd153fed4da0811abdcae","40122dcc63746bc8827d17f5f981668844e6c12537ca3b3e5e1a35883a50d666","966418bf54ad347058abfd433deb2f74480e859adb80b9ddbe90bb3ada1a8eda","e79130cf2ba010f2b79747bf43b086252ad041b130768331a1144c0a86185877","5945e7a91249d0dcbce4609279cb8fa95e45a4ac0edf6b81742724ed38e5018f","dafce7a7b279977940b6b4b50017625e4f922f73094433d2875994bdc0b27e87","ec630acd5a4ac97faadbfff935eb00df5318a6302dd25938685ad8c25c70465c","b3edafa3cb2b51781dffced9ec03613b8fe4981c44dea915f119996d201c1d03","6c559dee3c6251c261b67df08e01d4cbc89cbd7a63300150c636705733cebfff",{"version":"7cee402d3c866d1fd7c8f12275c6afc350690e4e17db6bd6b86bbb501466140b","affectsGlobalScope":true},"29d59e921bc723594bbfc98230d24f38f0d5a669f28fcf989b7468f4f95b7c52","55cf4bede47afcecc481168df3135bdf6f8e5c1bd0d78fb30981164ff165c3bc","9d9e658d1d5b805562749ce383ef8c67ccb796394d8734d9c138788d7dab6ee3","b0d10e46cfe3f6c476b69af02eaa38e4ccc7430221ce3109ae84bb9fb8282298","3d0ecc92ccecb9e84ec42359b2856155d138ad9ce0697d0504d30137cc2eded2","ed79be5ed3ed90fa6fc20f564c6badf90c0ded13407c41448df410a6b6cc37c3","ecb3f7a39c52816137f9a87278225ce7f522c6e493c46bb2fff2c2cc2ba0e2d4","4adfa22f05ddfdf98ea3741664a43debaa49fc0e6797bdf117f95219f5feb635","c5b3da7e2ecd5968f723282aba49d8d1a2e178d0afe48998dad93f81e2724091","3e4ba3ecd2f4b94e22c38ff57b944e43591cac6fd4d83e3f58157f04524d8da6","37d6dd79947b8c3f5eb759bd092d7c9b844d3655e547d16c3f2138d8d637674e","c96700cd147d5926d56ec9b45a66d6c8a86def5e94806157fa17c68831a6337f","033d38cf6f2db68b872d86c18ae9692f27627ef19797dc4bbbbc3cd6093eda42","95d085761c8e8d469a9066a9cc7bd4b5bc671098d2f8442ae657fb35b3215cf1","67483628398336d0f9368578a9514bd8cc823a4f3b3ab784f3942077e5047335","f7e133b20ee2669b6c0e5d7f0cd510868c57cd64b283e68c7f598e30ce9d76d2","6ba73232c9d3267ca36ddb83e335d474d2c0e167481e3dec416c782894e11438"],"options":{"allowUnreachableCode":true,"allowUnusedLabels":true,"composite":true,"esModuleInterop":true,"exactOptionalPropertyTypes":true,"module":1,"noFallthroughCasesInSwitch":true,"noImplicitAny":true,"noImplicitOverride":true,"noImplicitReturns":true,"noImplicitThis":true,"noUncheckedIndexedAccess":true,"noUnusedLocals":true,"noUnusedParameters":true,"outDir":"./","rootDir":"./","skipLibCheck":true,"sourceMap":true,"strict":true,"strictNullChecks":true,"strictPropertyInitialization":true,"target":2,"useUnknownInCatchVariables":true},"fileIdsList":[[45,95],[95],[45,46,47,48,49,95],[45,47,95],[70,95,102,103],[95,110],[70,95,102],[67,70,95,102,105,106],[95,104,106,107,109],[68,95,102],[95,116],[95,117],[95,102],[52,95],[55,95],[56,61,95],[57,67,68,75,84,94,95],[57,58,67,75,95],[59,95],[60,61,68,76,95],[61,84,91,95],[62,64,67,75,95],[63,95],[64,65,95],[66,67,95],[67,95],[67,68,69,84,94,95],[67,68,69,84,95],[70,75,84,94,95],[67,68,70,71,75,84,91,94,95],[70,72,84,91,94,95],[52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101],[67,73,95],[74,94,95],[64,67,75,84,95],[76,95],[77,95],[55,78,95],[79,93,95,99],[80,95],[81,95],[67,82,95],[82,83,95,97],[67,84,85,86,95],[84,86,95],[84,85,95],[87,95],[88,95],[67,89,90,95],[89,90,95],[61,75,91,95],[92,95],[75,93,95],[56,70,81,94,95],[61,95],[84,95,96],[95,97],[95,98],[56,61,67,69,78,84,94,95,97,99],[84,95,100],[95,102,124,126,130,131,132,133,134,135],[84,95,102],[67,95,102,124,126,127,129,136],[67,75,84,94,95,102,123,124,125,127,128,129,136],[84,95,102,126,127],[84,95,102,126,128],[95,102,124,126,127,129,136],[84,95,102,128],[67,75,84,91,95,102,125,127,129],[67,95,102,124,126,127,128,129,136],[67,84,95,102,124,125,126,127,128,129,136],[67,84,95,102,124,126,127,129,136],[70,84,95,102,129],[95,110,121,138],[95,110,137],[70,95,110],[70,95,102,108],[95,109,110],[95,144,145,146,147,148,149],[95,150],[95,153]],"referencedMap":[[47,1],[45,2],[50,3],[46,1],[48,4],[49,1],[51,2],[104,5],[111,6],[103,7],[112,6],[113,6],[114,6],[107,8],[110,9],[115,10],[116,2],[117,11],[118,12],[119,2],[120,2],[121,13],[108,2],[122,7],[52,14],[53,14],[55,15],[56,16],[57,17],[58,18],[59,19],[60,20],[61,21],[62,22],[63,23],[64,24],[65,24],[66,25],[67,26],[68,27],[69,28],[54,2],[101,2],[70,29],[71,30],[72,31],[102,32],[73,33],[74,34],[75,35],[76,36],[77,37],[78,38],[79,39],[80,40],[81,41],[82,42],[83,43],[84,44],[86,45],[85,46],[87,47],[88,48],[89,49],[90,50],[91,51],[92,52],[93,53],[94,54],[95,55],[96,56],[97,57],[98,58],[99,59],[100,60],[136,61],[123,62],[130,63],[126,64],[124,65],[127,66],[131,67],[132,63],[129,68],[128,69],[133,70],[134,71],[135,72],[125,73],[139,74],[138,75],[137,76],[140,2],[106,2],[105,2],[109,77],[141,2],[142,2],[143,78],[150,79],[144,80],[145,2],[146,2],[147,2],[148,2],[149,2],[151,2],[152,13],[153,2],[154,81],[9,2],[10,2],[14,2],[13,2],[3,2],[15,2],[16,2],[17,2],[18,2],[19,2],[20,2],[21,2],[22,2],[4,2],[5,2],[26,2],[23,2],[24,2],[25,2],[27,2],[28,2],[29,2],[6,2],[30,2],[31,2],[32,2],[33,2],[7,2],[34,2],[35,2],[36,2],[37,2],[8,2],[42,2],[38,2],[39,2],[40,2],[41,2],[2,2],[1,2],[43,2],[12,2],[11,2],[44,2]],"exportedModulesMap":[[47,1],[45,2],[50,3],[46,1],[48,4],[49,1],[51,2],[104,5],[111,6],[103,7],[112,6],[113,6],[114,6],[107,8],[110,9],[115,10],[116,2],[117,11],[118,12],[119,2],[120,2],[121,13],[108,2],[122,7],[52,14],[53,14],[55,15],[56,16],[57,17],[58,18],[59,19],[60,20],[61,21],[62,22],[63,23],[64,24],[65,24],[66,25],[67,26],[68,27],[69,28],[54,2],[101,2],[70,29],[71,30],[72,31],[102,32],[73,33],[74,34],[75,35],[76,36],[77,37],[78,38],[79,39],[80,40],[81,41],[82,42],[83,43],[84,44],[86,45],[85,46],[87,47],[88,48],[89,49],[90,50],[91,51],[92,52],[93,53],[94,54],[95,55],[96,56],[97,57],[98,58],[99,59],[100,60],[136,61],[123,62],[130,63],[126,64],[124,65],[127,66],[131,67],[132,63],[129,68],[128,69],[133,70],[134,71],[135,72],[125,73],[139,74],[138,75],[137,76],[140,2],[106,2],[105,2],[109,77],[141,2],[142,2],[143,78],[150,79],[144,80],[145,2],[146,2],[147,2],[148,2],[149,2],[151,2],[152,13],[153,2],[154,81],[9,2],[10,2],[14,2],[13,2],[3,2],[15,2],[16,2],[17,2],[18,2],[19,2],[20,2],[21,2],[22,2],[4,2],[5,2],[26,2],[23,2],[24,2],[25,2],[27,2],[28,2],[29,2],[6,2],[30,2],[31,2],[32,2],[33,2],[7,2],[34,2],[35,2],[36,2],[37,2],[8,2],[42,2],[38,2],[39,2],[40,2],[41,2],[2,2],[1,2],[43,2],[12,2],[11,2]],"semanticDiagnosticsPerFile":[47,45,50,46,48,49,51,104,111,103,112,113,114,107,110,115,116,117,118,119,120,121,108,122,52,53,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,54,101,70,71,72,102,73,74,75,76,77,78,79,80,81,82,83,84,86,85,87,88,89,90,91,92,93,94,95,96,97,98,99,100,136,123,130,126,124,127,131,132,129,128,133,134,135,125,139,138,137,140,106,105,109,141,142,143,150,144,145,146,147,148,149,151,152,153,154,9,10,14,13,3,15,16,17,18,19,20,21,22,4,5,26,23,24,25,27,28,29,6,30,31,32,33,7,34,35,36,37,8,42,38,39,40,41,2,1,43,12,11,44]},"version":"4.5.4"} --------------------------------------------------------------------------------