├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .github ├── dependabot.yml ├── workflows │ └── yarn.yml └── yml │ ├── npm.yml │ ├── pnpm.yml │ └── yarn.yml ├── .gitignore ├── .husky └── pre-push ├── .lintstagedrc ├── .prettierrc ├── LICENSE ├── README.md ├── ecosystem.config.js ├── jest.config.js ├── jest.setup.js ├── package.json ├── prod.tsconfig.json ├── src ├── __tests__ │ ├── auth.test.ts │ └── good.test.ts ├── bin │ └── index.js ├── config │ ├── db.ts │ ├── routes.ts │ └── server.ts ├── entities │ ├── admin │ │ ├── constants.ts │ │ ├── controller.ts │ │ ├── endpoints.ts │ │ ├── interface.ts │ │ ├── model.ts │ │ └── validation.ts │ ├── auth │ │ ├── constants.ts │ │ ├── controller.ts │ │ ├── endpoints.ts │ │ ├── interface.ts │ │ ├── model.ts │ │ └── validation.ts │ └── user │ │ ├── constants.ts │ │ ├── controller.ts │ │ ├── endpoints.ts │ │ ├── interface.ts │ │ ├── model.ts │ │ └── validation.ts ├── helpers │ ├── catchErrors.ts │ ├── customError.ts │ ├── handlePopulate.ts │ └── index.ts ├── index.ts ├── middlewares │ ├── errorHandler.ts │ ├── index.ts │ ├── limiter.ts │ ├── morgan.ts │ ├── notFound.ts │ └── permissions.ts ├── seeders │ ├── data │ │ └── users.json │ ├── seedAdmin.ts │ └── seedUsers.ts ├── services │ ├── auth.service.ts │ ├── crud.service.ts │ ├── logger.service.ts │ └── mail.service.ts ├── tasks │ ├── generateEntity.ts │ └── templates │ │ ├── default │ │ ├── constants.ts │ │ ├── controller.ts │ │ ├── endpoints.ts │ │ ├── interface.ts │ │ ├── model.ts │ │ └── validation.ts │ │ └── user │ │ ├── constants.ts │ │ ├── controller.ts │ │ ├── endpoints.ts │ │ ├── interface.ts │ │ ├── model.ts │ │ └── validation.ts └── types │ ├── environment.d.ts │ └── express │ └── index.d.ts ├── tsconfig.eslint.json ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # Database: 2 | DB_URI= 3 | # Testing Database: 4 | DB_URI_TEST= 5 | # Client Origin 6 | CLIENT_ORIGIN= 7 | # Server Port 8 | PORT=8080 9 | # JWT secrets 10 | JWT_ACCESS_SECRET= 11 | JWT_REFRESH_SECRET= 12 | # JWT expiration time 13 | JWT_ACCESS_EXPIRATION=15min 14 | JWT_REFRESH_EXPIRATION=7d 15 | # Refresh token endpoint 16 | REFRESH_TOKEN_ENDPOINT=/api/refresh 17 | # Admin Credentials 18 | ADMIN_EMAIL= 19 | ADMIN_PASSWORD= 20 | # NODE ENV 21 | NODE_ENV=development -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | coverage 5 | bin 6 | tasks 7 | .env 8 | .env.example 9 | .lintstagedrc -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es2021": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": [ 9 | "airbnb-base", 10 | "airbnb-typescript/base", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:eslint-comments/recommended", 13 | "plugin:jest/recommended", 14 | "plugin:promise/recommended", 15 | "prettier" 16 | ], 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "project": "./tsconfig.eslint.json" 20 | }, 21 | "plugins": ["@typescript-eslint", "eslint-comments", "jest", "promise", "import", "prettier"], 22 | "rules": { 23 | "prettier/prettier": "error", 24 | "@typescript-eslint/no-unused-vars": "off", 25 | "import/prefer-default-export": "off", 26 | "import/no-cycle": 0, 27 | "no-use-before-define": [ 28 | "error", 29 | { 30 | "functions": false, 31 | "classes": true, 32 | "variables": true 33 | } 34 | ], 35 | "@typescript-eslint/explicit-function-return-type": "off", 36 | "@typescript-eslint/no-use-before-define": [ 37 | "error", 38 | { 39 | "functions": false, 40 | "classes": true, 41 | "variables": true, 42 | "typedefs": true 43 | } 44 | ], 45 | "import/no-extraneous-dependencies": "off", 46 | "consistent-return": 0, 47 | "@typescript-eslint/no-explicit-any": 0, 48 | "no-param-reassign": 0, 49 | "no-underscore-dangle": ["error", { "allow": ["_id"] }] 50 | }, 51 | "settings": { 52 | "import/resolver": { 53 | "typescript": { 54 | "alwaysTryTypes": true, 55 | "project": "./tsconfig.json" 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/yarn.yml: -------------------------------------------------------------------------------- 1 | name: Lint/Test/Build with yarn 2 | 3 | on: 4 | push: 5 | branches: [main, dev] 6 | pull_request: 7 | branches: [main, dev] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [16.x] 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | cache: 'yarn' 22 | - name: Install dependencies 23 | run: yarn install 24 | - name: ESlint 25 | run: yarn lint 26 | 27 | test: 28 | runs-on: ubuntu-latest 29 | services: 30 | mongo: 31 | image: mongo 32 | ports: 33 | - 27017:27017 34 | strategy: 35 | matrix: 36 | node-version: [16.x] 37 | steps: 38 | - uses: actions/checkout@v2 39 | - name: Use Node.js ${{ matrix.node-version }} 40 | uses: actions/setup-node@v2 41 | with: 42 | node-version: ${{ matrix.node-version }} 43 | cache: 'yarn' 44 | - name: Install dependencies 45 | run: yarn install 46 | - name: Run tests with Jest 47 | run: yarn test 48 | env: 49 | DB_URI_TEST: mongodb://localhost:27017/testing 50 | JWT_ACCESS_SECRET: jwt-access-secret 51 | JWT_REFRESH_SECRET: jwt-refresh-secret 52 | JWT_ACCESS_EXPIRATION: 15min 53 | JWT_REFRESH_EXPIRATION: 1d 54 | REFRESH_TOKEN_ENDPOINT: /api/refresh 55 | 56 | build: 57 | runs-on: ubuntu-latest 58 | strategy: 59 | matrix: 60 | node-version: [16.x] 61 | steps: 62 | - uses: actions/checkout@v2 63 | - name: Use Node.js ${{ matrix.node-version }} 64 | uses: actions/setup-node@v2 65 | with: 66 | node-version: ${{ matrix.node-version }} 67 | cache: 'yarn' 68 | - name: Install dependencies 69 | run: yarn install 70 | - name: Build with TypeScript 71 | run: yarn build -------------------------------------------------------------------------------- /.github/yml/npm.yml: -------------------------------------------------------------------------------- 1 | name: Lint/Test/Build with npm 2 | 3 | on: 4 | push: 5 | branches: [main, dev] 6 | pull_request: 7 | branches: [main, dev] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [16.x] 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | cache: 'npm' 22 | - name: Install dependencies 23 | run: npm install 24 | - name: ESlint 25 | run: npm run lint 26 | 27 | test: 28 | runs-on: ubuntu-latest 29 | services: 30 | mongo: 31 | image: mongo 32 | ports: 33 | - 27017:27017 34 | strategy: 35 | matrix: 36 | node-version: [16.x] 37 | steps: 38 | - uses: actions/checkout@v2 39 | - name: Use Node.js ${{ matrix.node-version }} 40 | uses: actions/setup-node@v2 41 | with: 42 | node-version: ${{ matrix.node-version }} 43 | cache: 'npm' 44 | - name: Install dependencies 45 | run: npm install 46 | - name: Run tests with Jest 47 | run: npm run test 48 | env: 49 | DB_URI_TEST: mongodb://localhost:27017/testing 50 | JWT_ACCESS_SECRET: jwt-access-secret 51 | JWT_REFRESH_SECRET: jwt-refresh-secret 52 | JWT_ACCESS_EXPIRATION: 15min 53 | JWT_REFRESH_EXPIRATION: 1d 54 | REFRESH_TOKEN_ENDPOINT: /api/refresh 55 | 56 | build: 57 | runs-on: ubuntu-latest 58 | strategy: 59 | matrix: 60 | node-version: [16.x] 61 | steps: 62 | - uses: actions/checkout@v2 63 | - name: Use Node.js ${{ matrix.node-version }} 64 | uses: actions/setup-node@v2 65 | with: 66 | node-version: ${{ matrix.node-version }} 67 | cache: 'npm' 68 | - name: Install dependencies 69 | run: npm install 70 | - name: Build with TypeScript 71 | run: npm run build -------------------------------------------------------------------------------- /.github/yml/pnpm.yml: -------------------------------------------------------------------------------- 1 | name: Lint/Test/Build with pnpm 2 | 3 | on: 4 | push: 5 | branches: [main, dev] 6 | pull_request: 7 | branches: [main, dev] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [16.x] 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: pnpm/action-setup@v2.0.1 18 | with: 19 | version: 7.0.0 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'pnpm' 25 | - name: Install dependencies 26 | run: pnpm install 27 | - name: ESlint 28 | run: pnpm lint 29 | 30 | test: 31 | runs-on: ubuntu-latest 32 | services: 33 | mongo: 34 | image: mongo 35 | ports: 36 | - 27017:27017 37 | strategy: 38 | matrix: 39 | node-version: [16.x] 40 | steps: 41 | - uses: actions/checkout@v2 42 | - uses: pnpm/action-setup@v2.0.1 43 | with: 44 | version: 7.0.0 45 | - name: Use Node.js ${{ matrix.node-version }} 46 | uses: actions/setup-node@v2 47 | with: 48 | node-version: ${{ matrix.node-version }} 49 | cache: 'pnpm' 50 | - name: Install dependencies 51 | run: pnpm install 52 | - name: Run tests with Jest 53 | run: pnpm test 54 | env: 55 | DB_URI_TEST: mongodb://localhost:27017/testing 56 | JWT_ACCESS_SECRET: jwt-access-secret 57 | JWT_REFRESH_SECRET: jwt-refresh-secret 58 | JWT_ACCESS_EXPIRATION: 15min 59 | JWT_REFRESH_EXPIRATION: 1d 60 | REFRESH_TOKEN_ENDPOINT: /api/refresh 61 | 62 | build: 63 | runs-on: ubuntu-latest 64 | strategy: 65 | matrix: 66 | node-version: [16.x] 67 | steps: 68 | - uses: actions/checkout@v2 69 | - uses: pnpm/action-setup@v2.0.1 70 | with: 71 | version: 7.0.0 72 | - name: Use Node.js ${{ matrix.node-version }} 73 | uses: actions/setup-node@v2 74 | with: 75 | node-version: ${{ matrix.node-version }} 76 | cache: 'pnpm' 77 | - name: Install dependencies 78 | run: pnpm install 79 | - name: Build with TypeScript 80 | run: pnpm build -------------------------------------------------------------------------------- /.github/yml/yarn.yml: -------------------------------------------------------------------------------- 1 | name: Lint/Test/Build with yarn 2 | 3 | on: 4 | push: 5 | branches: [main, dev] 6 | pull_request: 7 | branches: [main, dev] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [16.x] 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | cache: 'yarn' 22 | - name: Install dependencies 23 | run: yarn install 24 | - name: ESlint 25 | run: yarn lint 26 | 27 | test: 28 | runs-on: ubuntu-latest 29 | services: 30 | mongo: 31 | image: mongo 32 | ports: 33 | - 27017:27017 34 | strategy: 35 | matrix: 36 | node-version: [16.x] 37 | steps: 38 | - uses: actions/checkout@v2 39 | - name: Use Node.js ${{ matrix.node-version }} 40 | uses: actions/setup-node@v2 41 | with: 42 | node-version: ${{ matrix.node-version }} 43 | cache: 'yarn' 44 | - name: Install dependencies 45 | run: yarn install 46 | - name: Run tests with Jest 47 | run: yarn test 48 | env: 49 | DB_URI_TEST: mongodb://localhost:27017/testing 50 | JWT_ACCESS_SECRET: jwt-access-secret 51 | JWT_REFRESH_SECRET: jwt-refresh-secret 52 | JWT_ACCESS_EXPIRATION: 15min 53 | JWT_REFRESH_EXPIRATION: 1d 54 | REFRESH_TOKEN_ENDPOINT: /api/refresh 55 | 56 | build: 57 | runs-on: ubuntu-latest 58 | strategy: 59 | matrix: 60 | node-version: [16.x] 61 | steps: 62 | - uses: actions/checkout@v2 63 | - name: Use Node.js ${{ matrix.node-version }} 64 | uses: actions/setup-node@v2 65 | with: 66 | node-version: ${{ matrix.node-version }} 67 | cache: 'yarn' 68 | - name: Install dependencies 69 | run: yarn install 70 | - name: Build with TypeScript 71 | run: yarn build -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Build 6 | build/ 7 | 8 | # Binaries 9 | # bin/ 10 | 11 | # Tests 12 | coverage/ 13 | 14 | # Dependencies 15 | node_modules/ 16 | 17 | # Environment variables 18 | .env 19 | 20 | # VS Code 21 | .vscode/ -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged && yarn test && yarn build -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.(js|ts)": "eslint --fix" 3 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "printWidth": 120, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "tabWidth": 2, 7 | "semi": true 8 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Adam Khomsi 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 |

2 | Typescript Node Express REST API 3 |

4 |

5 | 6 | Software License 7 | 8 | 9 | Latest Version 10 | 11 | 12 | Commitizen friendly 13 | 14 |

15 | 16 | # Introduction 17 | 18 | Create a maintainable and scalable Node.js REST API with TypeScript, Express and Mongoose. 19 | 20 | The project structure is based on MVC and follows it's basic principles but is a little bit different in which instead of having the entities logic spread out into specific folders (models folder containing all models, controllers folder containing all controllers etc...). 21 | 22 | Each entity has it's own folder containing all it's core logic in isolation from other entities. Let's take the `User` entity as an example: 23 | 24 | ``` 25 | src 26 | └── entities 27 | └── user 28 | ├── constants.ts 29 | ├── controller.ts 30 | ├── endpoints.ts 31 | ├── interface.ts 32 | ├── model.ts 33 | └── validation.ts 34 | ``` 35 | 36 | With this structure it is easier to maintain and scale with multiple entities (you will rarely have to switch between folders in order to manage one entity). 37 | 38 | The project comes with many built-in features, such as: 39 | 40 | - Authentication with [JWT](https://www.npmjs.com/package/jsonwebtoken): providing both an access token and refresh token (sent as a secure http only cookie and saved in the database). 41 | - Unified login system for support of multiple roles of users. 42 | - Validation using [Joi](https://joi.dev/). 43 | - [Jest](https://jestjs.io/) for unit and integration testing. 44 | - Entity folder/files generation with a custom script. 45 | - [PM2](https://pm2.keymetrics.io/) as a process manager. 46 | - Seeding data examples. 47 | - Logger with [winston](https://www.npmjs.com/package/winston) and [morgan](https://www.npmjs.com/package/morgan). 48 | - Error handling and a custom error catching method. 49 | - Filtering, sorting, field limiting, pagination. 50 | - Optional populate, select which fields to populate and which fields to return from GET requests. 51 | - more details below... 52 | 53 | # Table of Contents 54 | 55 | 56 | 57 | - [Setup](#setup) 58 | - [Usage](#usage) 59 | - [Configuration](#configuration) 60 | - [Directory Structure](#directory-structure) 61 | - [Scripts](#scripts) 62 | - [Features](#features) 63 | - [Contributions](#contributions) 64 | 65 | 66 | # Setup 67 | 68 | ## Usage 69 | 70 | To create a project, simply run: 71 | 72 | ```bash 73 | npx create-express-rest-ts my-app 74 | ``` 75 | 76 | or for a quick start if you are using vscode: 77 | 78 | ```bash 79 | npx create-express-rest-ts my-app 80 | cd my-app 81 | code . 82 | ``` 83 | 84 | *By default, it uses `yarn` to install dependencies. 85 | 86 | - If you prefer another package manager you can pass it as an argument: 87 | 88 | for `npm`: 89 | 90 | ```bash 91 | npx create-express-rest-ts my-app --npm 92 | ``` 93 | for `pnpm`: 94 | 95 | ```bash 96 | npx create-express-rest-ts my-app --pnpm 97 | ``` 98 | 99 | *You can pass package manager specific arguments as flags as well after the package manager argument. As an example with `npm` you might need to pass in the `--force` flag to force installation even with conflicting peer dependencies: 100 | 101 | ```bash 102 | npx create-express-rest-ts my-app --npm --force 103 | ``` 104 | 105 | Alternatively, you can clone the repository (or download or use as a template): 106 | 107 | ```bash 108 | git clone https://github.com/KhomsiAdam/create-express-rest-ts.git 109 | ``` 110 | 111 | Then open the project folder and install the required dependencies: 112 | 113 | ```bash 114 | yarn 115 | ``` 116 | 117 | *If you want to use another package manager after using this method instead of `npx`, before installing dependencies you should modify the `pre-commit` script in `.husky` to match your package manager of choice (then deleting the `yarn.lock` file if it would cause any conflicts). 118 | 119 | *In the `.github/yml` folder, there is a workflow file for each package manager. You can copy the file that matches your package manager into `.github/workflows` and delete `.github/workflows/yarn.yml`. 120 | 121 | [Back to top](#table-of-contents) 122 | 123 | ## Configuration 124 | 125 | Setup your environment variables. In your root directory, you will find a `.env.example` file. Copy and/or rename to `.env` or: 126 | 127 | ``` 128 | cp .env.example .env 129 | ``` 130 | 131 | Then: 132 | 133 | ```bash 134 | yarn dev 135 | ``` 136 | 137 | The database should be connected and your server should be running. You can start testing and querying the API. 138 | 139 | ```bash 140 | yarn test:good 141 | ``` 142 | 143 | [Back to top](#table-of-contents) 144 | 145 | # Directory Structure 146 | 147 | ``` 148 | src/ 149 | ├── __tests__/ # Groups all your integration tests and the testing server 150 | ├── config/ # Database, routes and server configurations 151 | ├── entities/ # Contains all entities (generated entities end up here with the custom script) 152 | ├── helpers/ # Any utility or helper functions/methods go here 153 | ├── middlewares/ # Express middlewares 154 | ├── seeders/ # Data seeders examples 155 | ├── services/ # Contains mostly global and reusable logic (such as auth and crud) 156 | ├── tasks/ # Scripts (contains the script to generate entities based of templates) 157 | │ └── templates/ # Contains entity templates (default and user type) 158 | ├── types/ # Custom/global type definitions 159 | └── index.ts # App entry point (initializes database connection and express server) 160 | ``` 161 | 162 | [Back to top](#table-of-contents) 163 | 164 | # Scripts 165 | 166 | - Run compiled javascript production build (requires build): 167 | 168 | ```bash 169 | yarn start 170 | ``` 171 | 172 |
173 | 174 | - Run compiled javascript production build with pm2 in cluster mode (requires build): 175 | 176 | ```bash 177 | yarn start:pm2 178 | ``` 179 | 180 |
181 | 182 | - Compiles typescript into javascript and build your app: 183 | 184 | ```bash 185 | yarn build 186 | ``` 187 | 188 |
189 | 190 | - Run the typescript development build: 191 | 192 | ```bash 193 | yarn dev 194 | ``` 195 | 196 |
197 | 198 | - Run the typescript development build with the `--trace-sync-io` tag to detect any synchronous I/O: 199 | 200 | ```bash 201 | yarn dev:sync 202 | ``` 203 | 204 |
205 | 206 | - Run the typescript development build with PM2: 207 | 208 | ```bash 209 | yarn dev:pm2 210 | ``` 211 | 212 |
213 | 214 | - Seed an Admin: 215 | 216 | ```bash 217 | yarn seed:admin 218 | ``` 219 | 220 |
221 | 222 | - Seed fake users based on json data file: 223 | 224 | ```bash 225 | yarn seed:users 226 | ``` 227 | 228 |
229 | 230 | - Generate an entity based of either the default or user template (prompts for a template selection and entity name, then create it's folder under `src/entities`) 231 | 232 | ```bash 233 | yarn entity 234 | ``` 235 | 236 | \*Entities created have their constants, controller (with basic crud), basic endpoints all automatically setup from the provided name. The interface, model and validation need to be filled with the needed fields. The endpoints are by default required to be authenticated and need to be imported into `src/config/routes.ts`. 237 | 238 |
239 | 240 | - Eslint (lint, lint and fix): 241 | 242 | ```bash 243 | yarn lint 244 | ``` 245 | 246 | ```bash 247 | yarn lint:fix 248 | ``` 249 | 250 |
251 | 252 | - Jest (all, unit, integration, coverage, watch, watchAll): 253 | 254 | ```bash 255 | yarn test 256 | ``` 257 | 258 | ```bash 259 | yarn test:unit 260 | ``` 261 | 262 | ```bash 263 | yarn test:int 264 | ``` 265 | 266 | ```bash 267 | yarn test:coverage 268 | ``` 269 | 270 | ```bash 271 | yarn test:watch 272 | ``` 273 | 274 | ```bash 275 | yarn test:watchAll 276 | ``` 277 | 278 |
279 | 280 | - PM2 (kill, monit): 281 | 282 | ```bash 283 | yarn kill 284 | ``` 285 | 286 | ```bash 287 | yarn monit 288 | ``` 289 | 290 |
291 | 292 | - Commitizen: 293 | 294 | ```bash 295 | yarn cz 296 | ``` 297 | 298 | [Back to top](#table-of-contents) 299 | 300 | # Features 301 | 302 | ## API Endpoints 303 | 304 | List of available routes: 305 | 306 | **Auth routes** (public):\ 307 | `POST /api/register` - register\ 308 | `POST /api/login` - login\ 309 | `POST /api/refresh` - refresh auth tokens\ 310 | `POST /api/logout` - logout 311 | 312 | **User routes** (private):\ 313 | `GET /api/users` - get all users\ 314 | `GET /api/users/:id` - get user by id\ 315 | `PATCH /api/users/:id` - update user\ 316 | `DELETE /api/users/:id` - delete user 317 | 318 | **Admin routes**:\ 319 | `GET /api/admins` - get all admins\ 320 | `GET /api/admins/:id` - get admin by id\ 321 | `PATCH /api/admins/:id` - update admin\ 322 | `DELETE /api/admins/:id` - delete admin 323 | 324 | \*The GET methods to get all elements of an entity have built in support for advanced queries as query parameters: 325 | 326 | - Filtering: `?field=value, ?field[gte]=value... (gte, gt, lte, lt, ne)` 327 | - Sorting: `sort=field (asc), sort=-field (desc), sort=field1,field2...` 328 | - Field limiting: `?fields=field1,field2,field3` 329 | - Pagination: `?page=2&limit=10 (page 1: 1-10, page 2: 11-20, page 3: 21-30...)` 330 | 331 | ## Entities 332 | 333 | let's imagine we generated using: 334 | 335 | ```bash 336 | yarn entity 337 | ``` 338 | 339 | a `Post` entity with the `default` template `src/entities/post`: 340 | 341 | ``` 342 | src 343 | └── entities 344 | └── post 345 | ├── constants.ts 346 | ├── controller.ts 347 | ├── endpoints.ts 348 | ├── interface.ts 349 | ├── model.ts 350 | └── validation.ts 351 | ``` 352 | 353 | It's constants, controller, endpoints are all ready and setup: 354 | 355 | `src/entities/post/constants.ts`: 356 | 357 | ```typescript 358 | export enum SuccessMessages { 359 | POST_CREATED = 'Post created successfully.', 360 | POST_UPDATED = 'Post updated successfully.', 361 | POST_DELETED = 'Post deleted successfully.', 362 | } 363 | 364 | export enum ErrorMessages { 365 | POSTS_NOT_FOUND = 'No posts found.', 366 | POST_NOT_FOUND = 'Post was not found.', 367 | } 368 | ``` 369 | 370 | `src/entities/post/controller.ts`: 371 | 372 | ```typescript 373 | import type { Request, Response, NextFunction } from 'express'; 374 | import * as controller from '@services/crud.service'; 375 | 376 | import { catchErrors } from '@helpers/catchErrors'; 377 | import { PostModel } from './model'; 378 | import { createPostSchema, updatePostSchema } from './validation'; 379 | import { SuccessMessages, ErrorMessages } from './constants'; 380 | 381 | export const create = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 382 | controller.create(req, res, next, createPostSchema, PostModel, SuccessMessages.POST_CREATED); 383 | }); 384 | 385 | export const getAll = catchErrors(async (_req: Request, res: Response, next: NextFunction) => { 386 | controller.getAll(_req, res, next, PostModel, ErrorMessages.POSTS_NOT_FOUND); 387 | }); 388 | 389 | export const getById = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 390 | controller.getByField(req, res, next, PostModel, ErrorMessages.POST_NOT_FOUND); 391 | }); 392 | 393 | export const update = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 394 | controller.update( 395 | req, 396 | res, 397 | next, 398 | updatePostSchema, 399 | PostModel, 400 | SuccessMessages.POST_UPDATED, 401 | ErrorMessages.POST_NOT_FOUND, 402 | ); 403 | }); 404 | 405 | export const remove = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 406 | controller.remove(req, res, next, PostModel, SuccessMessages.POST_DELETED, ErrorMessages.POST_NOT_FOUND); 407 | }); 408 | ``` 409 | 410 | The `getAll` and `getByField` methods of the main crud controller have optional options for managing referenced documents. By default `populate` is `false`. If set to true, you can choose which fields you would like to populate, and also return specified fields from the referenced documents, for example we can alter the `getAll` methods for posts: 411 | 412 | ```typescript 413 | export const getAll = catchErrors(async (_req: Request, res: Response, next: NextFunction) => { 414 | controller.getAll(_req, res, next, PostModel, ErrorMessages.POSTS_NOT_FOUND, true, 'user', 'firstname lastname'); 415 | }); 416 | ``` 417 | 418 | \*With this, we will get all posts with only the firstname and lastname of the referenced user. 419 | 420 | The `getByField` by default gets an element by id provided in as a path parameter `/api/user/:id`. 421 | 422 | If we want let's say, get the user by his email, we would need to create another method named `getByEmail` using the same method `getByField` only specifying `email` as the specified field: 423 | 424 | ```typescript 425 | export const getByEmail = catchErrors(async (_req: Request, res: Response, next: NextFunction) => { 426 | controller.getByField(_req, res, next, UserModel, ErrorMessages.USER_NOT_FOUND, 'email'); 427 | }); 428 | ``` 429 | 430 | Then we want to add it's endpoint under `src/entities/user/endpoints.ts`: 431 | 432 | ```typescript 433 | endpoints.get('/email/:email', is.Auth, user.getByEmail); 434 | ``` 435 | 436 | `src/entities/post/endpoints.ts`: 437 | 438 | ```typescript 439 | import { Router } from 'express'; 440 | import { is } from '@middlewares/permissions'; 441 | import * as post from './controller'; 442 | 443 | const endpoints = Router(); 444 | 445 | endpoints.post('/', is.Auth, post.create); 446 | endpoints.get('/', is.Auth, post.getAll); 447 | endpoints.get('/:id', is.Auth, post.getById); 448 | endpoints.patch('/:id', is.Auth, post.update); 449 | endpoints.delete('/:id', is.Auth, post.remove); 450 | 451 | export default endpoints; 452 | ``` 453 | 454 | \*Endpoints by default have the `is.Auth` permission that require a user to be authenticated to access them, you can either omit it if you want an endpoint to be public, or specify which permission is needed from `src/middlewares/permissions.ts`: 455 | 456 | ```typescript 457 | import type { NextFunction, Request, Response } from 'express'; 458 | import { verifyAuth } from '@services/auth.service'; 459 | import { Roles, Permissions } from '@entities/auth/constants'; 460 | 461 | export const is = { 462 | Auth: async (req: Request, res: Response, next: NextFunction): Promise => { 463 | verifyAuth(req, res, next); 464 | }, 465 | Self: async (req: Request, res: Response, next: NextFunction): Promise => { 466 | verifyAuth(req, res, next, undefined, Permissions.SELF); 467 | }, 468 | Own: async (req: Request, res: Response, next: NextFunction): Promise => { 469 | verifyAuth(req, res, next, undefined, Permissions.OWN); 470 | }, 471 | Admin: async (req: Request, res: Response, next: NextFunction): Promise => { 472 | verifyAuth(req, res, next, Roles.ADMIN); 473 | }, 474 | User: async (req: Request, res: Response, next: NextFunction): Promise => { 475 | verifyAuth(req, res, next, Roles.USER); 476 | }, 477 | }; 478 | ``` 479 | 480 | - `is.Self`: Used to only allow user to perform an operation (usually `update` or `delete`) on itself. 481 | 482 | - `is.Own`: Checks for the requested resource if it contains a reference of the user's ID to verify ownership. Used to restrict operations such as `update` or `delete` for the user who owns the resource only. 483 | 484 | *The resource needs to have a reference to a user. 485 | 486 | - `is.Admin`, `is.User`: Checks for the authorized role. 487 | 488 | \*The endpoints of each created entity must be imported into `src/config/routes.ts`: 489 | 490 | ```typescript 491 | import { Router } from 'express'; 492 | 493 | import authEndpoints from '@entities/auth/endpoints'; 494 | import adminEndpoints from '@entities/admin/endpoints'; 495 | import userEndpoints from '@entities/user/endpoints'; 496 | import postEndpoints from '@entities/post/endpoints'; 497 | 498 | const router = Router(); 499 | 500 | router.use('/', authEndpoints); 501 | router.use('/admins', adminEndpoints); 502 | router.use('/users', userEndpoints); 503 | router.use('/posts', postEndpoints); 504 | 505 | export default router; 506 | ``` 507 | 508 | The interface, model and validation will have to be filled by the needed fields. 509 | 510 | `src/entities/post/interface.ts`: 511 | 512 | ```typescript 513 | export interface PostEntity {} 514 | ``` 515 | 516 | `src/entities/post/model.ts`: 517 | 518 | ```typescript 519 | import { Schema, model } from 'mongoose'; 520 | 521 | import { PostEntity } from './interface'; 522 | 523 | const PostSchema = new Schema({}, { timestamps: true }); 524 | 525 | export const PostModel = model('Post', PostSchema); 526 | ``` 527 | 528 | `src/entities/post/validation.ts`: 529 | 530 | ```typescript 531 | import Joi from 'joi'; 532 | 533 | export const createPostSchema = Joi.object({}); 534 | 535 | export const updatePostSchema = Joi.object({}); 536 | ``` 537 | 538 | The `user` entity template slightly differs from the default one as it is destined for another type of user (another role for example). 539 | 540 | Using: 541 | 542 | ```bash 543 | yarn entity 544 | ``` 545 | 546 | Let's create a `Manager` entity with the `user` template `src/entities/manager`. 547 | 548 | `src/entities/manager/constants.ts`: 549 | 550 | ```typescript 551 | export enum SuccessMessages { 552 | MANAGER_UPDATED = 'Manager updated successfully.', 553 | MANAGER_DELETED = 'Manager deleted successfully.', 554 | } 555 | 556 | export enum ErrorMessages { 557 | MANAGERS_NOT_FOUND = 'No managers found.', 558 | MANAGER_NOT_FOUND = 'Manager was not found.', 559 | } 560 | 561 | export const SALT_ROUNDS = 12; 562 | ``` 563 | 564 | `src/entities/manager/controller.ts`: 565 | 566 | ```typescript 567 | import type { Request, Response, NextFunction } from 'express'; 568 | import * as controller from '@services/crud.service'; 569 | 570 | import { catchErrors } from '@helpers/catchErrors'; 571 | import { ManagerModel } from './model'; 572 | import { managerSchema } from './validation'; 573 | import { SuccessMessages, ErrorMessages } from './constants'; 574 | 575 | export const getAll = catchErrors(async (_req: Request, res: Response, next: NextFunction) => { 576 | controller.getAll(_req, res, next, ManagerModel, ErrorMessages.MANAGERS_NOT_FOUND); 577 | }); 578 | 579 | export const getById = catchErrors(async (_req: Request, res: Response, next: NextFunction) => { 580 | controller.getByField(_req, res, next, ManagerModel, ErrorMessages.MANAGER_NOT_FOUND); 581 | }); 582 | 583 | export const update = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 584 | controller.update( 585 | req, 586 | res, 587 | next, 588 | managerSchema, 589 | ManagerModel, 590 | SuccessMessages.MANAGER_UPDATED, 591 | ErrorMessages.MANAGER_NOT_FOUND, 592 | ); 593 | }); 594 | 595 | export const remove = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 596 | controller.remove(req, res, next, ManagerModel, SuccessMessages.MANAGER_DELETED, ErrorMessages.MANAGER_NOT_FOUND); 597 | }); 598 | ``` 599 | 600 | `src/entities/manager/endpoints.ts`: 601 | 602 | ```typescript 603 | import { Router } from 'express'; 604 | import { is } from '@middlewares/permissions'; 605 | import * as manager from './controller'; 606 | 607 | const endpoints = Router(); 608 | 609 | endpoints.get('/', is.Auth, manager.getAll); 610 | endpoints.get('/:id', is.Auth, manager.getById); 611 | endpoints.patch('/:id', is.Own, manager.update); 612 | endpoints.delete('/:id', is.Own, manager.remove); 613 | 614 | export default endpoints; 615 | ``` 616 | 617 | `src/entities/manager/interface.ts`: 618 | 619 | ```typescript 620 | import { Types } from 'mongoose'; 621 | 622 | export interface ManagerEntity { 623 | email: string; 624 | password: string; 625 | firstname: string; 626 | lastname: string; 627 | role?: Types.ObjectId; 628 | } 629 | ``` 630 | 631 | `src/entities/manager/model.ts`: 632 | 633 | ```typescript 634 | import { Schema, model } from 'mongoose'; 635 | import { hash as bcryptHash, genSalt as bcryptGenSalt } from 'bcryptjs'; 636 | 637 | import { AuthModel } from '@entities/auth/model'; 638 | import type { ManagerEntity } from './interface'; 639 | import { SALT_ROUNDS } from './constants'; 640 | 641 | const ManagerSchema = new Schema( 642 | { 643 | email: { 644 | type: String, 645 | required: true, 646 | unique: true, 647 | }, 648 | password: { 649 | type: String, 650 | required: true, 651 | select: false, 652 | }, 653 | firstname: { 654 | type: String, 655 | required: true, 656 | }, 657 | lastname: { 658 | type: String, 659 | required: true, 660 | }, 661 | role: { 662 | type: Schema.Types.ObjectId, 663 | ref: 'Auth', 664 | }, 665 | }, 666 | { timestamps: true }, 667 | ); 668 | 669 | // Before creating a manager 670 | ManagerSchema.pre('save', async function save(next) { 671 | // Only hash password if it has been modified or new 672 | if (!this.isModified('password')) return next(); 673 | // Generate salt and hash password 674 | const salt = await bcryptGenSalt(SALT_ROUNDS); 675 | this.password = await bcryptHash(this.password, salt); 676 | next(); 677 | }); 678 | // After creating a manager 679 | ManagerSchema.post('save', async (doc) => { 680 | // Create manager in auth collection 681 | await AuthModel.create({ email: doc.email, role: 'Manager' }); 682 | }); 683 | ManagerSchema.post('findOneAndDelete', async (doc) => { 684 | // Delete manager from auth collection 685 | await AuthModel.deleteOne({ email: doc.email }); 686 | }); 687 | 688 | export const ManagerModel = model('Manager', ManagerSchema); 689 | ``` 690 | 691 | `src/entities/post/validation.ts`: 692 | 693 | ```typescript 694 | import Joi from 'joi'; 695 | 696 | export const managerSchema = Joi.object({ 697 | firstname: Joi.string().trim(), 698 | lastname: Joi.string().trim(), 699 | }); 700 | ``` 701 | 702 | After importing the endpoints to the router `src/config/routes.ts` to register the schema, the `Manager` role should be added to the `Roles` constant `src/entities/auth/constants.ts`: 703 | 704 | ```typescript 705 | export enum Roles { 706 | ADMIN = 'Admin', 707 | USER = 'User', 708 | MANAGER = 'Manager', 709 | } 710 | ``` 711 | 712 | *It automatically get added into the `src/entities/auth/interface.ts` and `src/entities/auth/model.ts`. 713 | 714 | Then optionally add another permission `is.Manager` to check if user has a `Manager` role at `src/middlewares/permissions.ts`: 715 | 716 | ```typescript 717 | import type { NextFunction, Request, Response } from 'express'; 718 | import { verifyAuth } from '@services/auth.service'; 719 | import { Roles, Permissions } from '@entities/auth/constants'; 720 | 721 | export const is = { 722 | Auth: async (req: Request, res: Response, next: NextFunction): Promise => { 723 | verifyAuth(req, res, next); 724 | }, 725 | Self: async (req: Request, res: Response, next: NextFunction): Promise => { 726 | verifyAuth(req, res, next, undefined, Permissions.SELF); 727 | }, 728 | Own: async (req: Request, res: Response, next: NextFunction): Promise => { 729 | verifyAuth(req, res, next, undefined, Permissions.OWN); 730 | }, 731 | Admin: async (req: Request, res: Response, next: NextFunction): Promise => { 732 | verifyAuth(req, res, next, Roles.ADMIN); 733 | }, 734 | User: async (req: Request, res: Response, next: NextFunction): Promise => { 735 | verifyAuth(req, res, next, Roles.USER); 736 | }, 737 | Manager: async (req: Request, res: Response, next: NextFunction): Promise => { 738 | verifyAuth(req, res, next, Roles.MANAGER); 739 | }, 740 | }; 741 | ``` 742 | 743 | Now to create a user with a specified role, just send the role needed as part of the request body, it will automatically check if that role exists, if not the register will fail. 744 | 745 | *By default, registering creates user with a `User` role, and you cannot create a user with an `Admin` role with regular registering. 746 | 747 | ## Error Handling 748 | 749 | By wrapping the controller methods with the `catchErrors` wrapper, it catches any errors and forwards them to the error handling middleware. 750 | 751 | ```typescript 752 | import type { Request, Response, NextFunction, RequestHandler } from 'express'; 753 | 754 | export const catchErrors = 755 | (requestHandler: RequestHandler): RequestHandler => 756 | async (req: Request, res: Response, next: NextFunction): Promise => { 757 | try { 758 | return requestHandler(req, res, next); 759 | } catch (error) { 760 | next(error); 761 | } 762 | }; 763 | ``` 764 | 765 | As seen in the `getAll` method for users as an example: 766 | 767 | ```typescript 768 | export const getAll = catchErrors(async (_req: Request, res: Response, next: NextFunction) => { 769 | controller.getAll(_req, res, next, UserModel, ErrorMessages.USERS_NOT_FOUND); 770 | }); 771 | ``` 772 | 773 | There is also a `customErrors` method to send specified status code and message: 774 | 775 | ```typescript 776 | import type { Response, NextFunction } from 'express'; 777 | 778 | export const customError = (res: Response, next: NextFunction, message: any, code: number): void => { 779 | const error = new Error(message); 780 | res.status(code); 781 | next(error); 782 | }; 783 | ``` 784 | 785 | As is it used for the `notFound` middleware: 786 | 787 | ```typescript 788 | import { Request, Response, NextFunction } from 'express'; 789 | import { customError } from '@helpers/customError'; 790 | 791 | export const notFound = (req: Request, res: Response, next: NextFunction): void => { 792 | customError(res, next, `Not Found - ${req.originalUrl}`, 404); 793 | }; 794 | ``` 795 | 796 | \*When running in development mode, the error response contains the message but also the error stack split into an array for readability. 797 | 798 | ## Validation 799 | 800 | 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. 801 | 802 | The validation schemas are defined in the folder for each entity. Let's take the `User` entity as an example so it would be in: `src/entities/user/validation.ts`: 803 | 804 | ## Logging 805 | 806 | Import the logger from `src/services/logger.service.ts`. It is using the [winston](https://github.com/winstonjs/winston) logging library. 807 | 808 | Logging should be done according to the following severity levels (ascending order from most important to least important): 809 | 810 | ```typescript 811 | import { log } from '@services/logger.service'; 812 | log.error('error'); // level 0 813 | log.warn('warning'); // level 1 814 | log.info('information'); // level 2 815 | log.http('http'); // level 3 816 | log.debug('debug'); // level 4 817 | ``` 818 | 819 | In development mode, log messages of all severity levels will be printed to the console. 820 | 821 | HTTP requests are logged (using [morgan](https://github.com/expressjs/morgan)). 822 | 823 | ## WIP: 824 | - Reset, forgot password. 825 | - Email service. 826 | - File upload. 827 | 828 | [Back to top](#table-of-contents) 829 | 830 | # Contributions 831 | 832 | Contributions are welcome. To discuss any bugs, problems, fixes or improvements please refer to the [discussions](https://github.com/KhomsiAdam/create-express-rest-ts/discussions) section. 833 | 834 | Before creating a pull request, make sure to open an [issue](https://github.com/KhomsiAdam/create-express-rest-ts/issues) first. 835 | 836 | Committing your changes, fixes or improvements in a new branch with documentation will be appreciated. 837 | 838 | ## License 839 | 840 | [MIT](LICENSE) 841 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'server', 5 | script: 'build/index.js', 6 | automation: false, 7 | instances: '4', 8 | max_restarts: 5, 9 | env: { 10 | NODE_ENV: 'development', 11 | }, 12 | env_production: { 13 | NODE_ENV: 'production', 14 | }, 15 | }, 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | collectCoverageFrom: ['src/**/*.ts'], 6 | testPathIgnorePatterns: ['src/__tests__/test.server.ts'], 7 | coverageThreshold: { 8 | global: { 9 | branches: 0, 10 | functions: 0, 11 | lines: 0, 12 | statements: 0, 13 | }, 14 | }, 15 | // modulePaths: ['node_modules', '/src/'], 16 | // roots: ['node_modules', '/src/'], 17 | // modulePaths: ['node_modules', '/src/'], 18 | // moduleDirectories: ['node_modules', 'src'], 19 | roots: ['/src/'], 20 | transform: { '^.+\\.ts?$': 'ts-jest' }, 21 | moduleNameMapper: { 22 | '@config/(.*)': '/src/config/$1', 23 | '@entities/(.*)': '/src/entities/$1', 24 | '@helpers': '/src/helpers/index.ts', 25 | '@helpers/(.*)': '/src/helpers/$1', 26 | '@middlewares': '/src/middlewares/index.ts', 27 | '@middlewares/(.*)': '/src/middlewares/$1', 28 | '@seeders/(.*)': '/src/seeders/$1', 29 | '@services/(.*)': '/src/services/$1', 30 | '@tasks/(.*)': '/src/tasks/$1', 31 | '@types/(.*)': '/src/tasks/$1', 32 | }, 33 | setupFilesAfterEnv: ['./jest.setup.js'], 34 | }; 35 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | jest.setTimeout(50000); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-express-rest-ts", 3 | "version": "2.0.0", 4 | "description": "Set up and build a Node.js REST API using Typescript, Express, Mongoose with a maintainable and scalable structure.", 5 | "main": "src/index.ts", 6 | "bin": "src/bin/index.js", 7 | "scripts": { 8 | "start": "cross-env NODE_ENV=production node build/index.js", 9 | "start:pm2": "pm2-runtime start ecosystem.config.js --env production", 10 | "build": "ts-node -p prod.tsconfig.json && tsc-alias", 11 | "dev": "ts-node-dev --cls -r tsconfig-paths/register --respawn --transpile-only src/index.ts", 12 | "dev:sync": "ts-node-dev --cls -r tsconfig-paths/register --respawn --transpile-only src/index.ts --trace-sync-io", 13 | "dev:pm2": "pm2 start ecosystem.config.js", 14 | "seed:admin": "ts-node -r tsconfig-paths/register src/seeders/seedAdmin.ts", 15 | "seed:users": "ts-node -r tsconfig-paths/register src/seeders/seedUsers.ts", 16 | "entity": "ts-node src/tasks/generateEntity.ts", 17 | "lint": "eslint .", 18 | "lint:fix": "yarn lint --fix", 19 | "test": "jest --verbose", 20 | "test:good": "jest src/__tests__/good.test.ts", 21 | "test:unit": "jest unit", 22 | "test:int": "jest int", 23 | "test:coverage": "jest --coverage", 24 | "test:watch": "jest --watch", 25 | "test:watchAll": "jest --watchAll", 26 | "kill": "pm2 kill", 27 | "monit": "pm2 monit", 28 | "prepare": "husky install" 29 | }, 30 | "dependencies": { 31 | "bcryptjs": "^2.4.3", 32 | "compression": "^1.7.4", 33 | "cookie-parser": "^1.4.6", 34 | "cors": "^2.8.5", 35 | "dotenv": "^16.0.3", 36 | "express": "^4.18.2", 37 | "express-mongo-sanitize": "^2.2.0", 38 | "express-rate-limit": "^6.7.0", 39 | "helmet": "^6.0.1", 40 | "hpp": "^0.2.3", 41 | "inquirer": "^9.1.4", 42 | "joi": "^17.8.1", 43 | "jsonwebtoken": "^9.0.0", 44 | "mailgen": "^2.0.27", 45 | "mongoose": "^7.2.2", 46 | "morgan": "^1.10.0", 47 | "nodemailer": "^6.9.1", 48 | "pluralize": "^8.0.0", 49 | "pm2": "^5.2.2", 50 | "uuid": "^9.0.0", 51 | "winston": "^3.8.2" 52 | }, 53 | "devDependencies": { 54 | "@swc/core": "^1.3.37", 55 | "@swc/helpers": "^0.4.14", 56 | "@types/bcryptjs": "^2.4.2", 57 | "@types/compression": "^1.7.2", 58 | "@types/cookie-parser": "^1.4.3", 59 | "@types/cors": "^2.8.12", 60 | "@types/express": "^4.17.17", 61 | "@types/hpp": "^0.2.2", 62 | "@types/inquirer": "^9.0.3", 63 | "@types/jest": "^29.4.0", 64 | "@types/jsonwebtoken": "^9.0.1", 65 | "@types/morgan": "^1.9.4", 66 | "@types/node": "^20.2.5", 67 | "@types/nodemailer": "^6.4.7", 68 | "@types/pluralize": "^0.0.29", 69 | "@types/supertest": "^2.0.12", 70 | "@types/uuid": "^9.0.0", 71 | "@typescript-eslint/eslint-plugin": "^5.52.0", 72 | "@typescript-eslint/parser": "^5.0.0", 73 | "cross-env": "^7.0.3", 74 | "eslint": "^8.34.0", 75 | "eslint-config-airbnb-base": "^15.0.0", 76 | "eslint-config-airbnb-typescript": "^17.0.0", 77 | "eslint-config-prettier": "^8.6.0", 78 | "eslint-import-resolver-typescript": "^3.5.3", 79 | "eslint-plugin-eslint-comments": "^3.2.0", 80 | "eslint-plugin-import": "^2.27.5", 81 | "eslint-plugin-jest": "^27.2.1", 82 | "eslint-plugin-prettier": "^4.0.0", 83 | "eslint-plugin-promise": "^6.1.1", 84 | "husky": "^8.0.3", 85 | "jest": "^29.4.3", 86 | "lint-staged": "^13.1.2", 87 | "prettier": "^2.8.4", 88 | "regenerator-runtime": "^0.13.11", 89 | "supertest": "^6.3.3", 90 | "ts-jest": "^29.0.5", 91 | "ts-node": "^10.9.1", 92 | "ts-node-dev": "^2.0.0", 93 | "tsc-alias": "^1.8.2", 94 | "tsconfig-paths": "^3.14.2", 95 | "typescript": "^4.9.5" 96 | }, 97 | "repository": { 98 | "type": "git", 99 | "url": "git+https://github.com/KhomsiAdam/create-express-rest-ts.git" 100 | }, 101 | "keywords": [ 102 | "node", 103 | "express", 104 | "rest", 105 | "api", 106 | "typescript", 107 | "jwt", 108 | "accesstoken", 109 | "refreshtoken", 110 | "mongodb", 111 | "mongoose", 112 | "boilerplate" 113 | ], 114 | "author": "KhomsiAdam", 115 | "license": "MIT", 116 | "bugs": { 117 | "url": "https://github.com/KhomsiAdam/create-express-rest-ts/issues" 118 | }, 119 | "homepage": "https://github.com/KhomsiAdam/create-express-rest-ts#readme" 120 | } 121 | -------------------------------------------------------------------------------- /prod.tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": ["dom", "es6", "es2017", "es2019", "esnext.asynciterable"], 6 | "skipLibCheck": true, 7 | "sourceMap": true, 8 | "outDir": "./build", 9 | "removeComments": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "strictFunctionTypes": true, 13 | "noImplicitThis": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "allowSyntheticDefaultImports": true, 19 | "esModuleInterop": true, 20 | "emitDecoratorMetadata": true, 21 | "experimentalDecorators": true, 22 | "resolveJsonModule": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "baseUrl": "src", 25 | "paths": { 26 | "@*": ["./*"] 27 | }, 28 | "typeRoots": ["./src/types", "./node_modules/@types"] 29 | }, 30 | "ts-node": { 31 | "swc": true 32 | }, 33 | "exclude": ["node_modules", "./src/bin", "./src/__tests__", "./src/tasks", "./src/seeders", "./src/coverage"], 34 | "include": ["./src/**/*.ts"] 35 | } 36 | -------------------------------------------------------------------------------- /src/__tests__/auth.test.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import request from 'supertest'; 3 | import server from '@config/server'; 4 | 5 | const { DB_URI_TEST } = process.env; 6 | 7 | const user = { 8 | email: 'johndoe@email.com', 9 | password: 'johndoe123**', 10 | firstname: 'John', 11 | lastname: 'Doe', 12 | }; 13 | 14 | describe('Register', () => { 15 | beforeAll(async () => { 16 | mongoose.set('strictQuery', true); 17 | const { connection } = await mongoose.connect(DB_URI_TEST); 18 | const collections = await connection.db.collections(); 19 | collections.forEach(async (collection) => { 20 | await collection.drop(); 21 | }); 22 | }); 23 | afterAll(async () => { 24 | await mongoose.disconnect(); 25 | }); 26 | it('Check for missing fields', async () => { 27 | const response = await request(server).post('/api/register').send({ 28 | email: user.email, 29 | password: user.password, 30 | }); 31 | expect(response.statusCode).toBe(400); 32 | }); 33 | it('Check for email validity', async () => { 34 | const response = await request(server).post('/api/register').send({ 35 | email: 'invalid.email.com', 36 | password: user.password, 37 | firstname: user.firstname, 38 | lastname: user.lastname, 39 | }); 40 | expect(response.statusCode).toBe(400); 41 | }); 42 | it('Create user', async () => { 43 | const response = await request(server).post('/api/register').send(user); 44 | expect(response.statusCode).toBe(201); 45 | }); 46 | it('Do not create user with same email', async () => { 47 | const response = await request(server).post('/api/register').send(user); 48 | expect(response.statusCode).toBe(409); 49 | }); 50 | }); 51 | 52 | describe('Login', () => { 53 | let refreshToken: string; 54 | beforeAll(async () => { 55 | mongoose.set('strictQuery', true); 56 | await mongoose.connect(DB_URI_TEST); 57 | }); 58 | afterAll(async () => { 59 | await mongoose.disconnect(); 60 | }); 61 | it('Check for missing fields', async () => { 62 | const response = await request(server).post('/api/login').send({ 63 | email: user.email, 64 | }); 65 | expect(response.statusCode).toBe(400); 66 | }); 67 | it('Check for email validity', async () => { 68 | const response = await request(server).post('/api/login').send({ 69 | email: 'invalid.email.com', 70 | password: user.password, 71 | }); 72 | expect(response.statusCode).toBe(400); 73 | }); 74 | it('Try login with unregistered user', async () => { 75 | const response = await request(server).post('/api/login').send({ 76 | email: 'unregistered@email.com', 77 | password: 'unregistered123**', 78 | }); 79 | expect(response.statusCode).toBe(422); 80 | }); 81 | it('Login with correct credentials', async () => { 82 | const response = await request(server).post('/api/login').send({ email: user.email, password: user.password }); 83 | expect(response.statusCode).toBe(200); 84 | expect(response.body.token).toBeDefined(); 85 | expect(response.body.role).toBeDefined(); 86 | refreshToken = JSON.stringify(response.headers['set-cookie'][0]).split(';')[0].replace('"', ''); 87 | }); 88 | it('Refresh access token', async () => { 89 | const response = await request(server).post('/api/refresh').set('Cookie', refreshToken).expect(200); 90 | expect(response.body.token).toBeDefined(); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /src/__tests__/good.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import server from '@config/server'; 3 | 4 | describe('Good. 👌', () => { 5 | it('Good. 👌', async () => { 6 | const response = await request(server).get('/api'); 7 | expect(response.statusCode).toBe(200); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { execSync } = require('child_process'); 3 | const fs = require('fs'); 4 | 5 | const packageManagersArgs = ['--yarn', '--npm', '--pnpm']; 6 | 7 | const validatePackageManagerArg = (packageManagerArg) => { 8 | if (!packageManagersArgs.includes(packageManagerArg)) { 9 | console.error(`Invalid package manager argument: ${packageManagerArg}`); 10 | console.log('Package managers arguments supported: --yarn, --npm, --pnpm'); 11 | process.exit(-1); 12 | } 13 | return packageManagerArg.replace('--', ''); 14 | } 15 | 16 | const runCommand = (command) => { 17 | try { 18 | execSync(`${command}`, { stdio: 'inherit' }); 19 | } catch (error) { 20 | console.error(`Failed to execute ${command}`, error); 21 | return false; 22 | } 23 | return true; 24 | }; 25 | 26 | const projectName = process.argv[2] || 'express-gql-ts'; 27 | const packageManagerName = process.argv[3] ? validatePackageManagerArg(process.argv[3]) : 'yarn'; 28 | const arguments = process.argv[3] && process.argv[4] ? process.argv.slice(4).join(' ') : ''; 29 | 30 | const gitCheckout = `git clone --depth 1 https://github.com/KhomsiAdam/create-express-rest-ts ${projectName}`; 31 | const installDeps = `cd ${projectName} && ${packageManagerName} install ${arguments}`; 32 | 33 | const OS = process.platform; 34 | let currentDirCMD; 35 | if (OS === 'darwin' || OS === 'linux') currentDirCMD = 'pwd'; 36 | if (OS === 'win32') currentDirCMD = 'chdir'; 37 | 38 | const currentDir = execSync(currentDirCMD, { stdio: 'pipe' }).toString().trim(); 39 | 40 | const githubWorkflowsPath = `${currentDir}/${projectName}/.github/workflows`; 41 | const githubWorkYmlPath = `${currentDir}/${projectName}/.github/yml`; 42 | const huskyPreCommitPath = `${currentDir}/${projectName}/.husky/pre-commit`; 43 | const yarnLockPath = `${currentDir}/${projectName}/yarn.lock`; 44 | 45 | console.log(`Creating new Express TypeScript REST project: ${projectName}...`); 46 | const checkedOut = runCommand(gitCheckout); 47 | if (!checkedOut) process.exit(-1); 48 | 49 | if (packageManagerName !== 'yarn') { 50 | console.log(`Setup project for: ${packageManagerName}...`); 51 | const packageManagerRunCommand = packageManagerName === 'npm' ? 'npm run' : packageManagerName 52 | // Replace yarn commands with the selected package manager's commands 53 | fs.readFile(huskyPreCommitPath, 'utf8', (readError, data) => { 54 | if (readError) return console.log(readError); 55 | const result = data 56 | .replaceAll('yarn', `${packageManagerRunCommand}`) 57 | fs.writeFile(huskyPreCommitPath, result, 'utf8', (writeError) => { 58 | if (writeError) return console.log(writeError); 59 | }); 60 | }); 61 | // Remove github workflow yarn.yml file and copy the selected package manager's yml file 62 | fs.unlinkSync(`${githubWorkflowsPath}/yarn.yml`); 63 | fs.copyFileSync(`${githubWorkYmlPath}/${packageManagerName}.yml`, `${githubWorkflowsPath}/${packageManagerName}.yml`); 64 | } 65 | 66 | console.log(`Running: ${packageManagerName} install ${arguments}`); 67 | console.log('Installing dependencies...'); 68 | const installedDeps = runCommand(installDeps); 69 | if (!installedDeps) process.exit(-1); 70 | 71 | // Remove yarn.lock file if package manager is not yarn 72 | if (packageManagerName !== 'yarn') { 73 | fs.unlink(yarnLockPath, (err) => { 74 | if (err) throw err; 75 | }); 76 | } 77 | 78 | console.log('Project is ready! Follow the instructions in README.md to get started.'); -------------------------------------------------------------------------------- /src/config/db.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import mongoose from 'mongoose'; 3 | import { log } from '@services/logger.service'; 4 | 5 | const DB_URI = process.env.DB_URI as string; 6 | 7 | export const initializeDatabaseConnection = async () => { 8 | try { 9 | if (!DB_URI) return log.warn('Cannot connect to database: Credentials not provided.'); 10 | mongoose.set('strictQuery', true); 11 | const { connection } = await mongoose.connect(DB_URI); 12 | log.info(`Connected to database: ${connection.name}`); 13 | connection.on('error', (error) => { 14 | log.error(error || 'Cannot connect to database: Unknown error.'); 15 | }); 16 | connection.on('disconnected', () => { 17 | log.warn('Database connection was lost.'); 18 | }); 19 | connection.on('connected', () => { 20 | log.info('Database connection was restored.'); 21 | }); 22 | return; 23 | } catch (error) { 24 | return log.error(error); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/config/routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import authEndpoints from '@entities/auth/endpoints'; 4 | import adminEndpoints from '@entities/admin/endpoints'; 5 | import userEndpoints from '@entities/user/endpoints'; 6 | 7 | const router = Router(); 8 | 9 | router.use('/', authEndpoints); 10 | router.use('/admins', adminEndpoints); 11 | router.use('/users', userEndpoints); 12 | 13 | export default router; 14 | -------------------------------------------------------------------------------- /src/config/server.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import express from 'express'; 3 | import cookieParser from 'cookie-parser'; 4 | import cors from 'cors'; 5 | import compression from 'compression'; 6 | import helmet from 'helmet'; 7 | import mongoSanitize from 'express-mongo-sanitize'; 8 | import hpp from 'hpp'; 9 | 10 | import type { Express, Request, Response } from 'express'; 11 | 12 | import routes from '@config/routes'; 13 | import { morgan, notFound, errorHandler, limiter } from '@middlewares'; 14 | 15 | // Express Server 16 | const server: Express = express(); 17 | 18 | // Middlewares 19 | server.use(morgan); 20 | server.use(helmet()); 21 | server.use( 22 | cors({ 23 | origin: process.env.CLIENT_ORIGIN, 24 | credentials: true, 25 | }), 26 | ); 27 | server.use(compression()); 28 | server.use(cookieParser()); 29 | server.use('/api', limiter); 30 | server.use(express.json({ limit: '10kb' })); 31 | server.use(express.urlencoded({ extended: false })); 32 | server.use(mongoSanitize()); 33 | server.use( 34 | hpp({ 35 | whitelist: ['filter'], 36 | }), 37 | ); 38 | 39 | // Good. 👌 40 | server.get('/api', (_req: Request, res: Response) => { 41 | res.send('Good. 👌'); 42 | }); 43 | 44 | // Api routes 45 | server.use('/api', routes); 46 | 47 | // Error Handling 48 | server.use(notFound); 49 | server.use(errorHandler); 50 | 51 | export default server; 52 | -------------------------------------------------------------------------------- /src/entities/admin/constants.ts: -------------------------------------------------------------------------------- 1 | export enum SuccessMessages { 2 | ADMIN_UPDATED = 'Admin updated successfully.', 3 | ADMIN_DELETED = 'Admin deleted successfully.', 4 | } 5 | 6 | export enum ErrorMessages { 7 | ADMINS_NOT_FOUND = 'No admins found.', 8 | ADMIN_NOT_FOUND = 'Admin was not found.', 9 | } 10 | 11 | export const SALT_ROUNDS = 12; 12 | -------------------------------------------------------------------------------- /src/entities/admin/controller.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from 'express'; 2 | import * as controller from '@services/crud.service'; 3 | 4 | import { catchErrors } from '@helpers/catchErrors'; 5 | import { AdminModel } from './model'; 6 | import { adminSchema } from './validation'; 7 | import { SuccessMessages, ErrorMessages } from './constants'; 8 | 9 | export const getAll = catchErrors(async (_req: Request, res: Response, next: NextFunction) => { 10 | controller.getAll(_req, res, next, AdminModel, ErrorMessages.ADMINS_NOT_FOUND); 11 | }); 12 | 13 | export const getById = catchErrors(async (_req: Request, res: Response, next: NextFunction) => { 14 | controller.getByField(_req, res, next, AdminModel, ErrorMessages.ADMIN_NOT_FOUND); 15 | }); 16 | 17 | export const update = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 18 | controller.update( 19 | req, 20 | res, 21 | next, 22 | adminSchema, 23 | AdminModel, 24 | SuccessMessages.ADMIN_UPDATED, 25 | ErrorMessages.ADMIN_NOT_FOUND, 26 | ); 27 | }); 28 | 29 | export const remove = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 30 | controller.remove(req, res, next, AdminModel, SuccessMessages.ADMIN_DELETED, ErrorMessages.ADMIN_NOT_FOUND); 31 | }); 32 | -------------------------------------------------------------------------------- /src/entities/admin/endpoints.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { is } from '@middlewares/permissions'; 3 | import * as admin from './controller'; 4 | 5 | const endpoints = Router(); 6 | 7 | endpoints.get('/', is.Admin, admin.getAll); 8 | endpoints.get('/:id', is.Admin, admin.getById); 9 | endpoints.patch('/:id', is.Admin, admin.update); 10 | endpoints.delete('/:id', is.Admin, admin.remove); 11 | 12 | export default endpoints; 13 | -------------------------------------------------------------------------------- /src/entities/admin/interface.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | 3 | export interface AdminEntity { 4 | email: string; 5 | password: string; 6 | firstname: string; 7 | lastname: string; 8 | role?: Types.ObjectId; 9 | } 10 | -------------------------------------------------------------------------------- /src/entities/admin/model.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | import { hash as bcryptHash, genSalt as bcryptGenSalt } from 'bcryptjs'; 3 | 4 | import { AuthModel } from '@entities/auth/model'; 5 | import type { AdminEntity } from './interface'; 6 | import { SALT_ROUNDS } from './constants'; 7 | 8 | const AdminSchema = new Schema( 9 | { 10 | email: { 11 | type: String, 12 | required: true, 13 | unique: true, 14 | }, 15 | password: { 16 | type: String, 17 | required: true, 18 | select: false, 19 | }, 20 | firstname: { 21 | type: String, 22 | required: true, 23 | }, 24 | lastname: { 25 | type: String, 26 | required: true, 27 | }, 28 | role: { 29 | type: Schema.Types.ObjectId, 30 | ref: 'Auth', 31 | }, 32 | }, 33 | { timestamps: true }, 34 | ); 35 | 36 | // Before creating an admin 37 | AdminSchema.pre('save', async function save(next) { 38 | // Only hash password if it has been modified or new 39 | if (!this.isModified('password')) return next(); 40 | // Generate salt and hash password 41 | const salt = await bcryptGenSalt(SALT_ROUNDS); 42 | this.password = await bcryptHash(this.password, salt); 43 | next(); 44 | }); 45 | // After creating an admin 46 | AdminSchema.post('save', async (doc) => { 47 | // Create admin in auth collection 48 | await AuthModel.create({ email: doc.email, role: 'Admin' }); 49 | }); 50 | AdminSchema.post('findOneAndDelete', async (doc) => { 51 | // Delete admin from auth collection 52 | await AuthModel.deleteOne({ email: doc.email }); 53 | }); 54 | 55 | export const AdminModel = model('Admin', AdminSchema); 56 | -------------------------------------------------------------------------------- /src/entities/admin/validation.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | export const adminSchema = Joi.object({ 4 | firstname: Joi.string().trim(), 5 | lastname: Joi.string().trim(), 6 | }); 7 | -------------------------------------------------------------------------------- /src/entities/auth/constants.ts: -------------------------------------------------------------------------------- 1 | export enum Roles { 2 | ADMIN = 'Admin', 3 | USER = 'User', 4 | } 5 | 6 | export enum Permissions { 7 | SELF = 'Self', 8 | OWN = 'Own', 9 | } 10 | 11 | export enum SuccessMessages { 12 | REGISTER_SUCCESS = 'Account created successfully.', 13 | REFRESH_SUCCESS = 'Refresh token updated successfully.', 14 | LOGGED_IN = 'Logged in successfully.', 15 | LOGGED_OUT = 'Logged out successfully.', 16 | } 17 | 18 | export enum ErrorMessages { 19 | LOGIN_ERROR = 'Unable to login. Please try again.', 20 | NOT_LOGGED_IN = 'Not logged in. Please login.', 21 | REGISTER_ERROR = 'Unable to register. Please try again.', 22 | DUPLICATE_ERROR = 'User already exists with this email', 23 | NOT_AUTHORIZED = 'Not authorized. You do not have access to this ressource', 24 | NOT_AUTHENTICATED = 'Not authenticated. You must be logged in to perform this action.', 25 | FORBIDDEN = 'Forbidden. You do not have permission to perform this action.', 26 | TOKEN_EXPIRED = 'Token expired. Please login again.', 27 | } 28 | 29 | export const cookieName = 'rtkn'; 30 | export const passwordLength = 8; 31 | -------------------------------------------------------------------------------- /src/entities/auth/controller.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from 'express'; 2 | import { model } from 'mongoose'; 3 | import { verify as jwtVerify } from 'jsonwebtoken'; 4 | import { compare as bcryptCompare } from 'bcryptjs'; 5 | 6 | import { findUser, generateToken, clearRefreshToken, sendRefreshToken } from '@services/auth.service'; 7 | import { log } from '@services/logger.service'; 8 | import { catchErrors, customError } from '@helpers'; 9 | 10 | import { UserModel } from '@entities/user/model'; 11 | import { AuthModel } from './model'; 12 | import { loginSchema, registerSchema } from './validation'; 13 | import { SuccessMessages, ErrorMessages } from './constants'; 14 | import type { JwtErrors, PayloadData } from './interface'; 15 | 16 | const { JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, JWT_ACCESS_EXPIRATION, JWT_REFRESH_EXPIRATION } = process.env; 17 | 18 | // Register logic 19 | export const register = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 20 | // Register validation 21 | const { error } = registerSchema.validate(req.body); 22 | if (error) return customError(res, next, error, 400); 23 | // Find user by email 24 | const fetchedUser = await AuthModel.findOne({ 25 | email: req.body.email, 26 | }); 27 | // If user exists return error else create user 28 | if (fetchedUser && fetchedUser.role.toLowerCase() === 'admin') { 29 | return customError(res, next, ErrorMessages.REGISTER_ERROR, 422); 30 | } 31 | if (fetchedUser) return customError(res, next, ErrorMessages.DUPLICATE_ERROR, 409); 32 | // If a role is provided create user with the specified role else create user with default 'User' role 33 | if (req.body.role) { 34 | if (req.body.role.toLowerCase() === 'admin') return customError(res, next, ErrorMessages.REGISTER_ERROR, 422); 35 | // Check if schema/model for that role is registered 36 | try { 37 | // Capitalize the provided string 38 | const RoleModel = model(req.body.role.toUpperCase().charAt(0) + req.body.role.toLowerCase().slice(1)); 39 | // Remove role from body and create user 40 | delete req.body.role; 41 | const newUser = new RoleModel(req.body); 42 | await newUser.save(); 43 | res.json({ message: SuccessMessages.REGISTER_SUCCESS }); 44 | } catch { 45 | return customError(res, next, ErrorMessages.REGISTER_ERROR, 422); 46 | } 47 | } else { 48 | const newUser = new UserModel(req.body); 49 | await newUser.save(); 50 | res.statusCode = 201; 51 | res.json({ message: SuccessMessages.REGISTER_SUCCESS }); 52 | } 53 | }); 54 | 55 | // Login logic 56 | export const login = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 57 | const { error } = loginSchema.validate(req.body); 58 | if (error) return customError(res, next, error, 400); 59 | const foundUser = await findUser(req); 60 | if (!foundUser) return customError(res, next, ErrorMessages.LOGIN_ERROR, 422); 61 | const passwordCompareResult = await bcryptCompare(req.body.password, foundUser.password); 62 | if (!passwordCompareResult) return customError(res, next, ErrorMessages.LOGIN_ERROR, 422); 63 | const { cookies } = req; 64 | // Refresh token array handling 65 | let newRefreshTokenArray = !cookies?.rtkn 66 | ? req?.user?.refreshToken 67 | : req?.user?.refreshToken.filter((rt: string) => rt !== cookies.rtkn); 68 | // Detect refresh token reuse 69 | if (cookies?.rtkn) { 70 | const refreshToken = cookies?.rtkn; 71 | const compromisedUser = await AuthModel.findOne({ refreshToken }, 'refreshToken'); 72 | if (compromisedUser) { 73 | // Clear out all previous refresh tokens 74 | log.warn('Detected refresh token reuse at login.'); 75 | newRefreshTokenArray = []; 76 | // Deleting the compromised refresh token 77 | const compromisedUserTokens = compromisedUser.refreshToken.filter((rt: string) => rt !== cookies.rtkn); 78 | compromisedUser.refreshToken = [...compromisedUserTokens]; 79 | compromisedUser.save(); 80 | } 81 | } 82 | // Generate new refresh token 83 | const newRefreshToken = generateToken( 84 | foundUser._id, 85 | req.user._id, 86 | JWT_REFRESH_SECRET as string, 87 | JWT_REFRESH_EXPIRATION as string | number, 88 | ); 89 | // Saving refreshToken with current user 90 | req.user.refreshToken = [...newRefreshTokenArray, newRefreshToken]; 91 | await req.user.save(); 92 | // Set refresh token in http only cookie 93 | sendRefreshToken(res, newRefreshToken); 94 | // Send access token 95 | res.json({ 96 | token: generateToken( 97 | foundUser._id, 98 | req.user._id, 99 | JWT_ACCESS_SECRET as string, 100 | JWT_ACCESS_EXPIRATION as string | number, 101 | ), 102 | role: [foundUser.role], 103 | message: SuccessMessages.LOGGED_IN, 104 | }); 105 | }); 106 | 107 | // Refresh access token 108 | export const refresh = async (req: Request, res: Response, next: NextFunction) => { 109 | const refreshToken = req?.cookies?.rtkn; 110 | if (!refreshToken) return res.json({ message: ErrorMessages.NOT_LOGGED_IN }); 111 | const foundUser = await AuthModel.findOne({ refreshToken }, 'refreshToken role'); 112 | // Detect refresh token reuse 113 | if (!foundUser) { 114 | jwtVerify(refreshToken, JWT_REFRESH_SECRET as string, async (error: JwtErrors, decoded: any): Promise => { 115 | if (error) return customError(res, next, ErrorMessages.FORBIDDEN, 403); 116 | const payload = decoded as PayloadData; 117 | // Clear out all previous refresh tokens 118 | log.warn('Detected refresh token reuse at refresh.'); 119 | await AuthModel.updateOne({ _id: payload.roleId }, { $set: { refreshToken: [] } }); 120 | return customError(res, next, ErrorMessages.FORBIDDEN, 403); 121 | }); 122 | } else { 123 | // Handle refresh token array 124 | const newRefreshTokenArray = foundUser.refreshToken.filter((rt: string) => rt !== refreshToken); 125 | return jwtVerify(refreshToken, JWT_REFRESH_SECRET as string, async (error: JwtErrors, decoded: any) => { 126 | // Remove token from db if expired 127 | if (error) { 128 | foundUser.refreshToken = [...newRefreshTokenArray]; 129 | await foundUser.save(); 130 | return res.json({ message: ErrorMessages.TOKEN_EXPIRED }); 131 | } 132 | // Generate new refresh token 133 | const payload = decoded as PayloadData; 134 | const newRefreshToken = generateToken( 135 | payload.userId, 136 | payload.roleId, 137 | JWT_REFRESH_SECRET as string, 138 | JWT_REFRESH_EXPIRATION as string | number, 139 | ); 140 | // Saving refreshToken 141 | foundUser.refreshToken = [...newRefreshTokenArray, newRefreshToken]; 142 | await foundUser.save(); 143 | // Send new refresh and access tokens 144 | sendRefreshToken(res, newRefreshToken); 145 | return res.json({ 146 | token: generateToken( 147 | payload.userId, 148 | payload.roleId, 149 | JWT_ACCESS_SECRET as string, 150 | JWT_ACCESS_EXPIRATION as string | number, 151 | ), 152 | role: [foundUser.role], 153 | message: SuccessMessages.REFRESH_SUCCESS, 154 | }); 155 | }); 156 | } 157 | }; 158 | 159 | // Logout user, reset refresh token 160 | export const logout = async (req: Request, res: Response) => { 161 | const refreshToken = req?.cookies?.rtkn; 162 | if (!refreshToken) return res.json({ message: ErrorMessages.NOT_LOGGED_IN }); 163 | // Check if user have any refresh token in database 164 | const foundUser = await AuthModel.findOne({ refreshToken }, 'refreshToken'); 165 | if (!foundUser) { 166 | // Clear refresh token from cookie 167 | clearRefreshToken(res); 168 | return res.json({ message: SuccessMessages.LOGGED_OUT }); 169 | } 170 | // Delete refreshToken from db 171 | foundUser.refreshToken = foundUser.refreshToken.filter((rt: string) => rt !== refreshToken); 172 | await foundUser.save(); 173 | // Clear refresh token from cookie 174 | clearRefreshToken(res); 175 | return res.json({ message: SuccessMessages.LOGGED_OUT }); 176 | }; 177 | -------------------------------------------------------------------------------- /src/entities/auth/endpoints.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import * as auth from './controller'; 3 | 4 | const endpoints = Router(); 5 | 6 | endpoints.post('/login', auth.login); 7 | endpoints.post('/register', auth.register); 8 | endpoints.post('/refresh', auth.refresh); 9 | endpoints.post('/logout', auth.logout); 10 | 11 | export default endpoints; 12 | -------------------------------------------------------------------------------- /src/entities/auth/interface.ts: -------------------------------------------------------------------------------- 1 | import type { Types } from 'mongoose'; 2 | import type { VerifyErrors, JwtPayload } from 'jsonwebtoken'; 3 | 4 | import type { Roles } from './constants'; 5 | 6 | export interface AuthData { 7 | email: string; 8 | role: Roles; 9 | refreshToken: Array; 10 | } 11 | 12 | export interface FoundUserEntity { 13 | _id: Types.ObjectId; 14 | password: string; 15 | role: string; 16 | } 17 | 18 | // export interface FoundUserEntity { 19 | // user: { 20 | // _id: Types.ObjectId; 21 | // password: string; 22 | // role: Types.ObjectId | string; 23 | // }; 24 | // role: { 25 | // _id: Types.ObjectId; 26 | // role: string; 27 | // refreshToken: Array; 28 | // save: Document['save']; 29 | // }; 30 | // } 31 | 32 | export type MaybeUserEntity = FoundUserEntity | false; 33 | 34 | export interface PayloadData extends JwtPayload { 35 | userId: Types.ObjectId; 36 | roleId: Types.ObjectId; 37 | } 38 | 39 | export type JwtErrors = VerifyErrors | null; 40 | export type MaybeUser = FoundUserEntity | false; 41 | -------------------------------------------------------------------------------- /src/entities/auth/model.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | 3 | import { AdminModel } from '@entities/admin/model'; 4 | import { UserModel } from '@entities/user/model'; 5 | import { Roles } from './constants'; 6 | import type { AuthData } from './interface'; 7 | 8 | const AuthSchema = new Schema( 9 | { 10 | email: { 11 | type: String, 12 | required: true, 13 | unique: true, 14 | }, 15 | role: { 16 | type: String, 17 | enum: Object.values(Roles), 18 | required: true, 19 | }, 20 | refreshToken: [ 21 | { 22 | type: String, 23 | default: [], 24 | select: false, 25 | }, 26 | ], 27 | }, 28 | { timestamps: true }, 29 | ); 30 | 31 | // After creating a user 32 | AuthSchema.post('save', async (doc) => { 33 | // Add reference to role in user by email 34 | if (doc.role === 'Admin') { 35 | await AdminModel.updateOne({ email: doc.email }, { role: doc._id }); 36 | } else { 37 | await UserModel.updateOne({ email: doc.email }, { role: doc._id }); 38 | } 39 | }); 40 | 41 | export const AuthModel = model('Auth', AuthSchema); 42 | -------------------------------------------------------------------------------- /src/entities/auth/validation.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | import { passwordLength } from './constants'; 3 | 4 | const emailValidation = { 5 | minDomainSegments: 2, 6 | tlds: { allow: ['com', 'net'] }, 7 | }; 8 | 9 | export const registerSchema = Joi.object({ 10 | email: Joi.string().email(emailValidation).trim().required(), 11 | password: Joi.string().trim().min(passwordLength).required(), 12 | firstname: Joi.string().trim().required(), 13 | lastname: Joi.string().trim().required(), 14 | role: Joi.string().trim(), 15 | }); 16 | 17 | export const loginSchema = Joi.object({ 18 | email: Joi.string().email(emailValidation).trim().required(), 19 | password: Joi.string().trim().min(passwordLength).required(), 20 | }); 21 | -------------------------------------------------------------------------------- /src/entities/user/constants.ts: -------------------------------------------------------------------------------- 1 | export enum SuccessMessages { 2 | USER_UPDATED = 'User updated successfully.', 3 | USER_DELETED = 'User deleted successfully.', 4 | } 5 | 6 | export enum ErrorMessages { 7 | USERS_NOT_FOUND = 'No users found.', 8 | USER_NOT_FOUND = 'User was not found.', 9 | } 10 | 11 | export const SALT_ROUNDS = 12; 12 | -------------------------------------------------------------------------------- /src/entities/user/controller.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from 'express'; 2 | import * as controller from '@services/crud.service'; 3 | 4 | import { catchErrors } from '@helpers/catchErrors'; 5 | import { UserModel } from './model'; 6 | import { userSchema } from './validation'; 7 | import { SuccessMessages, ErrorMessages } from './constants'; 8 | 9 | export const getAll = catchErrors(async (_req: Request, res: Response, next: NextFunction) => { 10 | controller.getAll(_req, res, next, UserModel, ErrorMessages.USERS_NOT_FOUND); 11 | }); 12 | 13 | export const getById = catchErrors(async (_req: Request, res: Response, next: NextFunction) => { 14 | controller.getByField(_req, res, next, UserModel, ErrorMessages.USER_NOT_FOUND); 15 | }); 16 | 17 | export const update = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 18 | controller.update(req, res, next, userSchema, UserModel, SuccessMessages.USER_UPDATED, ErrorMessages.USER_NOT_FOUND); 19 | }); 20 | 21 | export const remove = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 22 | controller.remove(req, res, next, UserModel, SuccessMessages.USER_DELETED, ErrorMessages.USER_NOT_FOUND); 23 | }); 24 | -------------------------------------------------------------------------------- /src/entities/user/endpoints.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { is } from '@middlewares/permissions'; 3 | import * as user from './controller'; 4 | 5 | const endpoints = Router(); 6 | 7 | endpoints.get('/', is.Auth, user.getAll); 8 | endpoints.get('/:id', is.Auth, user.getById); 9 | endpoints.patch('/:id', is.Self, user.update); 10 | endpoints.delete('/:id', is.Admin, user.remove); 11 | 12 | export default endpoints; 13 | -------------------------------------------------------------------------------- /src/entities/user/interface.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | 3 | export interface UserEntity { 4 | email: string; 5 | password: string; 6 | firstname: string; 7 | lastname: string; 8 | role?: Types.ObjectId; 9 | } 10 | -------------------------------------------------------------------------------- /src/entities/user/model.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | import { hash as bcryptHash, genSalt as bcryptGenSalt } from 'bcryptjs'; 3 | 4 | import { AuthModel } from '@entities/auth/model'; 5 | import type { UserEntity } from './interface'; 6 | import { SALT_ROUNDS } from './constants'; 7 | 8 | const UserSchema = new Schema( 9 | { 10 | email: { 11 | type: String, 12 | required: true, 13 | unique: true, 14 | }, 15 | password: { 16 | type: String, 17 | required: true, 18 | select: false, 19 | }, 20 | firstname: { 21 | type: String, 22 | required: true, 23 | }, 24 | lastname: { 25 | type: String, 26 | required: true, 27 | }, 28 | role: { 29 | type: Schema.Types.ObjectId, 30 | ref: 'Auth', 31 | }, 32 | }, 33 | { timestamps: true }, 34 | ); 35 | 36 | // Before creating a user 37 | UserSchema.pre('save', async function save(next) { 38 | // Only hash password if it has been modified or new 39 | if (!this.isModified('password')) return next(); 40 | // Generate salt and hash password 41 | const salt = await bcryptGenSalt(SALT_ROUNDS); 42 | this.password = await bcryptHash(this.password, salt); 43 | next(); 44 | }); 45 | // After creating a user 46 | UserSchema.post('save', async (doc) => { 47 | // Create user in auth collection 48 | await AuthModel.create({ email: doc.email, role: 'User' }); 49 | }); 50 | UserSchema.post('findOneAndDelete', async (doc) => { 51 | // Delete user from auth collection 52 | await AuthModel.deleteOne({ email: doc.email }); 53 | }); 54 | 55 | export const UserModel = model('User', UserSchema); 56 | -------------------------------------------------------------------------------- /src/entities/user/validation.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | export const userSchema = Joi.object({ 4 | firstname: Joi.string().trim(), 5 | lastname: Joi.string().trim(), 6 | }); 7 | -------------------------------------------------------------------------------- /src/helpers/catchErrors.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction, RequestHandler } from 'express'; 2 | 3 | export const catchErrors = 4 | (requestHandler: RequestHandler): RequestHandler => 5 | async (req: Request, res: Response, next: NextFunction): Promise => { 6 | try { 7 | return requestHandler(req, res, next); 8 | } catch (error) { 9 | next(error); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /src/helpers/customError.ts: -------------------------------------------------------------------------------- 1 | import type { Response, NextFunction } from 'express'; 2 | 3 | export const customError = (res: Response, next: NextFunction, message: any, code: number): void => { 4 | const error = new Error(message); 5 | res.status(code); 6 | next(error); 7 | }; 8 | -------------------------------------------------------------------------------- /src/helpers/handlePopulate.ts: -------------------------------------------------------------------------------- 1 | import type { Document } from 'mongoose'; 2 | 3 | export const handlePopulate = async ( 4 | documentQuery: any, 5 | populate: boolean, 6 | populateFields: string, 7 | selectedFields: string, 8 | ): Promise => { 9 | if (populate && populateFields !== '' && selectedFields === '') return documentQuery.populate(populateFields); 10 | if (populate && populateFields !== '' && selectedFields !== '') 11 | return documentQuery.populate(populateFields, selectedFields); 12 | return documentQuery; 13 | }; 14 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export { catchErrors } from './catchErrors'; 2 | export { customError } from './customError'; 3 | export { handlePopulate } from './handlePopulate'; 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import server from '@config/server'; 2 | import { initializeDatabaseConnection } from '@config/db'; 3 | import { log } from '@services/logger.service'; 4 | 5 | const port = process.env.PORT || 8080; 6 | 7 | const initializeServer = async (): Promise => { 8 | await initializeDatabaseConnection(); 9 | server.listen(port, () => { 10 | log.info(`Server ready at: http://localhost:${port}/api`); 11 | }); 12 | }; 13 | 14 | initializeServer(); 15 | -------------------------------------------------------------------------------- /src/middlewares/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from 'express'; 2 | import { log } from '@services/logger.service'; 3 | 4 | interface ErrorHandler { 5 | message: string; 6 | stack: string; 7 | } 8 | 9 | export const errorHandler = (err: ErrorHandler, _req: Request, res: Response, next: NextFunction): void => { 10 | res.status(res.statusCode || 500); 11 | if (process.env.NODE_ENV !== 'production') { 12 | res.json({ 13 | message: err.message, 14 | stack: err.stack.split('\n '), 15 | }); 16 | } else { 17 | res.json({ 18 | message: err.message, 19 | }); 20 | } 21 | log.error(err.message); 22 | next(); 23 | }; 24 | -------------------------------------------------------------------------------- /src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export { errorHandler } from './errorHandler'; 2 | export { is } from './permissions'; 3 | export { limiter } from './limiter'; 4 | export { default as morgan } from './morgan'; 5 | export { notFound } from './notFound'; 6 | -------------------------------------------------------------------------------- /src/middlewares/limiter.ts: -------------------------------------------------------------------------------- 1 | import rateLimit from 'express-rate-limit'; 2 | 3 | export const limiter = rateLimit({ 4 | max: 100, 5 | windowMs: 60 * 60 * 1000, 6 | standardHeaders: false, 7 | legacyHeaders: false, 8 | message: 'You exceeded the allowed number of requests, please try again in an hour.', 9 | }); 10 | -------------------------------------------------------------------------------- /src/middlewares/morgan.ts: -------------------------------------------------------------------------------- 1 | import morgan from 'morgan'; 2 | import { log } from '@services/logger.service'; 3 | 4 | // Use custom Winston logger 5 | const stream = { 6 | write: (message: string) => log.http(message.substring(0, message.lastIndexOf('\n'))), 7 | }; 8 | 9 | // Skip all the Morgan http log if app is not running in development 10 | const skip = (): boolean => { 11 | const env = process.env.NODE_ENV || 'development'; 12 | return env !== 'development'; 13 | }; 14 | 15 | // Build the morgan middleware 16 | const morganMiddleware = morgan( 17 | // Define message format string, and stream 18 | ':method :url :status :res[content-length] - :response-time ms', 19 | { stream, skip }, 20 | ); 21 | 22 | export default morganMiddleware; 23 | -------------------------------------------------------------------------------- /src/middlewares/notFound.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { customError } from '@helpers/customError'; 3 | 4 | export const notFound = (req: Request, res: Response, next: NextFunction): void => { 5 | customError(res, next, `Not Found - ${req.originalUrl}`, 404); 6 | }; 7 | -------------------------------------------------------------------------------- /src/middlewares/permissions.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from 'express'; 2 | import { verifyAuth } from '@services/auth.service'; 3 | import { Roles, Permissions } from '@entities/auth/constants'; 4 | 5 | export const is = { 6 | Auth: async (req: Request, res: Response, next: NextFunction): Promise => { 7 | verifyAuth(req, res, next); 8 | }, 9 | Self: async (req: Request, res: Response, next: NextFunction): Promise => { 10 | verifyAuth(req, res, next, undefined, Permissions.SELF); 11 | }, 12 | Own: async (req: Request, res: Response, next: NextFunction): Promise => { 13 | verifyAuth(req, res, next, undefined, Permissions.OWN); 14 | }, 15 | Admin: async (req: Request, res: Response, next: NextFunction): Promise => { 16 | verifyAuth(req, res, next, Roles.ADMIN); 17 | }, 18 | User: async (req: Request, res: Response, next: NextFunction): Promise => { 19 | verifyAuth(req, res, next, Roles.USER); 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /src/seeders/data/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "email": "leanne@email.com", 4 | "password": "leanne123**", 5 | "firstname": "Leanne", 6 | "lastname": "Graham" 7 | }, 8 | { 9 | "email": "ervin@email.com", 10 | "password": "ervin123**", 11 | "firstname": "Ervin", 12 | "lastname": "Howell" 13 | }, 14 | { 15 | "email": "clementine@email.com", 16 | "password": "clementine123**", 17 | "firstname": "Clementine", 18 | "lastname": "Bauch" 19 | }, 20 | { 21 | "email": "patricia@email.com", 22 | "password": "patricia123**", 23 | "firstname": "Patricia", 24 | "lastname": "Lebsack" 25 | }, 26 | { 27 | "email": "kurtis@email.com", 28 | "password": "kurtis123**", 29 | "firstname": "Kurtis", 30 | "lastname": "Weissnat" 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /src/seeders/seedAdmin.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import mongoose from 'mongoose'; 3 | 4 | import { log } from '@services/logger.service'; 5 | import { AdminModel } from '@entities/admin/model'; 6 | 7 | const { DB_URI, ADMIN_EMAIL, ADMIN_PASSWORD } = process.env; 8 | 9 | const seedAdmin = (): void => { 10 | mongoose.set('strictQuery', true); 11 | mongoose.connect(DB_URI as string, async () => { 12 | try { 13 | const findAdmin = await AdminModel.findOne({ email: ADMIN_EMAIL }); 14 | if (!findAdmin) { 15 | const admin = new AdminModel({ 16 | email: ADMIN_EMAIL, 17 | password: ADMIN_PASSWORD, 18 | firstname: 'Admin', 19 | lastname: 'Admin', 20 | }); 21 | await admin.save(); 22 | log.debug('Admin created!'); 23 | mongoose.disconnect(); 24 | } else { 25 | log.debug('Admin already exists!'); 26 | mongoose.disconnect(); 27 | } 28 | } catch (error) { 29 | log.debug(error); 30 | mongoose.disconnect(); 31 | } 32 | }); 33 | }; 34 | 35 | seedAdmin(); 36 | -------------------------------------------------------------------------------- /src/seeders/seedUsers.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import mongoose from 'mongoose'; 3 | import { genSaltSync as bcryptGenSaltSync, hashSync as bcryptHashSync } from 'bcryptjs'; 4 | 5 | import { log } from '@services/logger.service'; 6 | import { AuthModel } from '@entities/auth/model'; 7 | import { UserModel } from '@entities/user/model'; 8 | 9 | import users from './data/users.json'; 10 | 11 | const { DB_URI } = process.env; 12 | 13 | const seedUsers = async () => { 14 | mongoose.set('strictQuery', true); 15 | mongoose.connect(DB_URI as string, async () => { 16 | try { 17 | // Get all users by email 18 | const emails = users.map((user) => user.email); 19 | // Delete all seeded users by email 20 | await AuthModel.deleteMany({ email: { $in: emails } }); 21 | await UserModel.deleteMany({ email: { $in: emails } }); 22 | // Get all users by email and role 23 | const roles = users.map((user) => ({ 24 | email: user.email, 25 | role: 'User', 26 | })); 27 | // Insert all users by email and role in Auth collection 28 | const seededRoles = await AuthModel.insertMany(roles, { ordered: false }); 29 | // Filter users and match by email and add id to each user 30 | const filteredUsers = users.filter((user: any) => { 31 | const userMatch = seededRoles.find((role) => role.email === user.email); 32 | if (userMatch) { 33 | user.role = userMatch.id; 34 | const salt = bcryptGenSaltSync(12); 35 | user.password = bcryptHashSync(user.password, salt); 36 | return user; 37 | } 38 | return null; 39 | }); 40 | // Insert all filtered users in User collection 41 | await UserModel.insertMany(filteredUsers, { ordered: false }); 42 | log.debug('Users seeded!'); 43 | mongoose.disconnect(); 44 | } catch (error) { 45 | log.debug(error); 46 | mongoose.disconnect(); 47 | } 48 | }); 49 | }; 50 | 51 | seedUsers(); 52 | -------------------------------------------------------------------------------- /src/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from 'express'; 2 | import { Types, model, isValidObjectId } from 'mongoose'; 3 | import { sign as jwtSign, verify as jwtVerify } from 'jsonwebtoken'; 4 | import pluralize from 'pluralize'; 5 | 6 | import { AuthModel } from '@entities/auth/model'; 7 | import type { FoundUserEntity, MaybeUser, JwtErrors, PayloadData } from '@entities/auth/interface'; 8 | import { cookieName, ErrorMessages, Permissions } from '@entities/auth/constants'; 9 | import { customError } from '@helpers/customError'; 10 | 11 | const { JWT_ACCESS_SECRET, REFRESH_TOKEN_ENDPOINT } = process.env; 12 | 13 | // Access Token generation when login 14 | export const generateToken = ( 15 | payloadUserId: Types.ObjectId, 16 | payloadRoleId: Types.ObjectId, 17 | secret: string, 18 | expiration: string | number, 19 | ): string => { 20 | const payload: PayloadData = { 21 | userId: payloadUserId, 22 | roleId: payloadRoleId, 23 | }; 24 | return jwtSign(payload, secret as string, { 25 | expiresIn: expiration, 26 | }); 27 | }; 28 | 29 | // Send refresh token and set to cookie 30 | export const sendRefreshToken = (res: Response, token: string): void => { 31 | res.cookie(cookieName, token, { 32 | httpOnly: true, 33 | sameSite: 'none', 34 | secure: true, 35 | path: REFRESH_TOKEN_ENDPOINT, 36 | maxAge: 24 * 60 * 60 * 1000, 37 | }); 38 | }; 39 | 40 | // Clear refresh token from cookie 41 | export const clearRefreshToken = (res: Response): void => { 42 | res.clearCookie(cookieName, { 43 | httpOnly: true, 44 | sameSite: 'none', 45 | secure: true, 46 | path: REFRESH_TOKEN_ENDPOINT, 47 | }); 48 | }; 49 | 50 | // Verify token and user role if provided 51 | export const verifyAuthAlt = async (req: Request, res: Response, next: NextFunction, role = ''): Promise => { 52 | const authHeader = req.get('Authorization'); 53 | if (!authHeader?.startsWith('Bearer ')) return customError(res, next, ErrorMessages.NOT_AUTHENTICATED, 401); 54 | const token = authHeader?.split(' ')[1]; 55 | if (!token) return customError(res, next, ErrorMessages.NOT_AUTHENTICATED, 401); 56 | jwtVerify(token, JWT_ACCESS_SECRET as string, async (error: JwtErrors, decoded: any) => { 57 | if (error) return customError(res, next, ErrorMessages.TOKEN_EXPIRED, 401); 58 | if (role === '') return next(); 59 | const payload = decoded as PayloadData; 60 | const authorizedUser = await model(role).findOne({ _id: payload.userId }); 61 | if (!authorizedUser) return customError(res, next, ErrorMessages.NOT_AUTHORIZED, 403); 62 | next(); 63 | }); 64 | }; 65 | 66 | // Verify token and user role if provided 67 | export const verifyAuth = async ( 68 | req: Request, 69 | res: Response, 70 | next: NextFunction, 71 | role?: string, 72 | permission?: string, 73 | ) => { 74 | // Get authorization header 75 | const authHeader = req.get('Authorization'); 76 | // Check for 'Bearer' scheme 77 | if (!authHeader?.startsWith('Bearer ')) return customError(res, next, ErrorMessages.NOT_AUTHENTICATED, 401); 78 | // Check for token 79 | const token = authHeader?.split(' ')[1]; 80 | if (!token) return customError(res, next, ErrorMessages.NOT_AUTHENTICATED, 401); 81 | try { 82 | const decoded = jwtVerify(token, JWT_ACCESS_SECRET as string) as PayloadData; 83 | // Put the decoded token payload in the request 84 | req.decoded = decoded; 85 | // Check permission for operations on self 86 | const { id } = req.params; 87 | if (permission && permission === Permissions.SELF && id && !new Types.ObjectId(id).equals(decoded.userId)) 88 | return customError(res, next, ErrorMessages.NOT_AUTHORIZED, 403); 89 | // Check permission for operations on owned entities 90 | if (permission && permission === Permissions.OWN && id) { 91 | let isMatchedRefId = false; 92 | // Get model name from baseUrl and singularize it, then capitalize it 93 | const modelName = pluralize.singular(req.baseUrl.split('/')[2]); 94 | const capitalizedModelName = modelName.toUpperCase().charAt(0) + modelName.toLowerCase().slice(1); 95 | const findOwnedEntity = await model(capitalizedModelName) 96 | .findOne({ _id: id }) 97 | .select(['-_id', '-createdAt', '-updatedAt', '-__v']) 98 | .lean(); 99 | // Check if userId exists in the entity as reference 100 | if (findOwnedEntity) { 101 | Object.entries(findOwnedEntity).forEach(([_key, val]: any) => { 102 | if (isValidObjectId(val) && new Types.ObjectId(val).equals(decoded.userId)) isMatchedRefId = true; 103 | }); 104 | } 105 | if (isMatchedRefId) return next(); 106 | if (!isMatchedRefId) return customError(res, next, ErrorMessages.NOT_AUTHORIZED, 403); 107 | } 108 | // Token verified and no role provided, user is authenticated 109 | if (!role && decoded) return next(); 110 | // When role is provided check if user exists with id from token 111 | if (role) { 112 | const authorizedUser = await model(role).findOne({ _id: decoded.userId }); 113 | if (!authorizedUser) return customError(res, next, ErrorMessages.NOT_AUTHORIZED, 403); 114 | return next(); 115 | } 116 | } catch (error) { 117 | return customError(res, next, error.message, 401); 118 | } 119 | }; 120 | 121 | // Finding the existence of a user 122 | export const findUser = async (req: Request): Promise => { 123 | // Get the user's role and refresh tokens by email from the auth/role collection 124 | const fetchedRole = await AuthModel.findOne({ email: req.body.email }, 'role refreshToken'); 125 | if (!fetchedRole) return false; 126 | // Get the user's password by email from the specified collection from the role 127 | const fetchedUser = (await model(fetchedRole.role).findOne({ email: req.body.email }, 'password')) as FoundUserEntity; 128 | if (!fetchedUser) return false; 129 | // Put the user role and refresh tokens in the request and return the fetched user 130 | req.user = fetchedRole; 131 | return { 132 | _id: fetchedUser._id, 133 | password: fetchedUser.password, 134 | role: fetchedRole.role, 135 | }; 136 | }; 137 | -------------------------------------------------------------------------------- /src/services/crud.service.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from 'express'; 2 | import { Model, isValidObjectId } from 'mongoose'; 3 | import type { ObjectSchema } from 'joi'; 4 | 5 | import { customError, handlePopulate } from '@helpers'; 6 | 7 | export const create = async ( 8 | req: Request, 9 | res: Response, 10 | next: NextFunction, 11 | entitySchema: ObjectSchema, 12 | EntityModel: Model, 13 | successMessage: string, 14 | ) => { 15 | const { error } = entitySchema.validate(req.body); 16 | if (error) return customError(res, next, error, 400); 17 | const newEntity = new EntityModel(req.body); 18 | await newEntity.save(); 19 | res.statusCode = 201; 20 | res.json({ message: successMessage }); 21 | }; 22 | 23 | export const getAll = async ( 24 | req: Request, 25 | res: Response, 26 | next: NextFunction, 27 | EntityModel: Model, 28 | errorMessage: string, 29 | populate = false, 30 | populateFields = '', 31 | selectedFields = '', 32 | ) => { 33 | // Extract special query params 34 | const queryObj = { ...req.query }; 35 | const excludedFields = ['page', 'sort', 'limit', 'fields']; 36 | excludedFields.forEach((el) => delete queryObj[el]); 37 | // Filtering: ?field=value, ?field[gte]=value... (gte, gt, lte, lt, ne) 38 | const queryString = JSON.stringify(queryObj).replace(/\b(gte|gt|lte|lt|ne)\b/g, (match) => `$${match}`); 39 | // Sorting: sort=field (asc), sort=-field (desc), sort=field1,field2... 40 | const sortBy = req?.query?.sort ? (req?.query?.sort as string).split(',').join(' ') : '-createdAt'; 41 | // Field Limiting: ?fields=field1,field2,field3 42 | const fields = req?.query?.fields ? (req?.query?.fields as string).split(',').join(' ') : '-__v'; 43 | // Pagination: ?page=2&limit=10 (page 1: 1-10, page 2: 11-20, page 3: 21-30...) 44 | type PaginationLimit = number | bigint | any; 45 | const page = (req?.query?.page as PaginationLimit) * 1 || 1; 46 | const limit = (req?.query?.limit as PaginationLimit) * 1 || 100; 47 | const skip = (page - 1) * limit; 48 | // Optionally populate and choose selected fields 49 | const foundEntities = await handlePopulate( 50 | EntityModel.find(JSON.parse(queryString)).sort(sortBy).select(fields).skip(skip).limit(limit), 51 | populate, 52 | populateFields, 53 | selectedFields, 54 | ); 55 | if (!foundEntities) return customError(res, next, errorMessage, 404); 56 | res.json(foundEntities); 57 | }; 58 | 59 | export const getByField = async ( 60 | req: Request, 61 | res: Response, 62 | next: NextFunction, 63 | EntityModel: Model, 64 | errorMessage: string, 65 | field = '_id', 66 | populate = false, 67 | populateFields = '', 68 | selectedFields = '', 69 | ) => { 70 | // Get the path parameter depending on the field specified (defaults to _id) 71 | const param = req.params[field === '_id' ? 'id' : field]; 72 | // Check if the parameter is a valid ObjectId if left as default 73 | if (field === '_id' && !isValidObjectId(param)) return customError(res, next, 'Invalid path parameter', 400); 74 | // Setup the query 75 | const documentQuery = await EntityModel.findOne({ [field]: param }); 76 | // Optionally populate and choose selected fields 77 | const foundEntity = await handlePopulate(documentQuery, populate, populateFields, selectedFields); 78 | if (!foundEntity) return customError(res, next, errorMessage, 404); 79 | res.json(foundEntity); 80 | }; 81 | 82 | export const update = async ( 83 | req: Request, 84 | res: Response, 85 | next: NextFunction, 86 | entitySchema: ObjectSchema, 87 | EntityModel: Model, 88 | successMessage: string, 89 | errorMessage: string, 90 | ) => { 91 | const { error } = entitySchema.validate(req.body); 92 | if (error) return customError(res, next, error, 422); 93 | if (!isValidObjectId(req.params.id)) return customError(res, next, 'Invalid path parameter', 400); 94 | const updatedEntity = await EntityModel.findOneAndUpdate({ _id: req.params.id }, { $set: req.body }, { new: true }); 95 | if (!updatedEntity) return customError(res, next, errorMessage, 404); 96 | res.json({ updatedEntity, message: successMessage }); 97 | }; 98 | 99 | export const remove = async ( 100 | req: Request, 101 | res: Response, 102 | next: NextFunction, 103 | EntityModel: Model, 104 | successMessage: string, 105 | errorMessage: string, 106 | ) => { 107 | if (!isValidObjectId(req.params.id)) return customError(res, next, 'Invalid path parameter', 400); 108 | const deletedEntity = await EntityModel.findOneAndDelete({ _id: req.params.id }); 109 | if (!deletedEntity) return customError(res, next, errorMessage, 404); 110 | res.json({ deletedEntity, message: successMessage }); 111 | }; 112 | -------------------------------------------------------------------------------- /src/services/logger.service.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | 3 | const levels = { 4 | error: 0, 5 | warn: 1, 6 | info: 2, 7 | http: 3, 8 | debug: 4, 9 | }; 10 | 11 | const level = () => { 12 | const env = process.env.NODE_ENV || 'development'; 13 | const isDevelopment = env === 'development'; 14 | return isDevelopment ? 'debug' : 'warn'; 15 | }; 16 | 17 | const colors = { 18 | error: 'red', 19 | warn: 'yellow', 20 | info: 'green', 21 | http: 'blue', 22 | debug: 'magenta', 23 | }; 24 | 25 | winston.addColors(colors); 26 | 27 | const format = winston.format.combine( 28 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }), 29 | winston.format.printf((info) => `${info.timestamp} ${info.level}: ${info.message}`), 30 | ); 31 | 32 | const transports = [ 33 | new winston.transports.Console({ 34 | format: winston.format.combine(winston.format.colorize(), format), 35 | }), 36 | new winston.transports.File({ 37 | filename: 'logs/error.log', 38 | level: 'error', 39 | }), 40 | new winston.transports.File({ filename: 'logs/all.log' }), 41 | ]; 42 | 43 | export const log = winston.createLogger({ 44 | level: level(), 45 | levels, 46 | format, 47 | transports, 48 | }); 49 | -------------------------------------------------------------------------------- /src/services/mail.service.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from 'nodemailer'; 2 | import Mailgen from 'mailgen'; 3 | import { log } from './logger.service'; 4 | 5 | const mail = { 6 | name: 'name', 7 | link: 'link', 8 | intro: 'intro', 9 | instructions: 'instructions', 10 | color: '#22BC66', 11 | text: 'text', 12 | outro: 'outro', 13 | subject: 'subject', 14 | }; 15 | 16 | // Configure mailgen by setting a theme and your product info 17 | const mailGenerator = new Mailgen({ 18 | theme: 'default', 19 | product: { 20 | name: mail.name, 21 | link: mail.link, 22 | }, 23 | }); 24 | 25 | const generatedEmail = () => { 26 | const content = { 27 | body: { 28 | intro: mail.intro, 29 | action: { 30 | instructions: mail.instructions, 31 | button: { 32 | color: mail.color, 33 | text: mail.text, 34 | link: mail.link, 35 | }, 36 | }, 37 | outro: mail.outro, 38 | }, 39 | }; 40 | return content; 41 | }; 42 | 43 | export const sendMail = async (email: string) => { 44 | const emailBody = mailGenerator.generate(generatedEmail()); 45 | const emailText = mailGenerator.generatePlaintext(generatedEmail()); 46 | 47 | const testAccount = await nodemailer.createTestAccount(); 48 | 49 | const transporter = nodemailer.createTransport({ 50 | host: 'smtp.ethereal.email', 51 | port: 587, 52 | secure: false, 53 | auth: { 54 | user: testAccount.user, 55 | pass: testAccount.pass, 56 | }, 57 | }); 58 | 59 | const info = await transporter.sendMail({ 60 | from: `"${mail.name}" <${testAccount.user}>`, 61 | to: email, 62 | subject: mail.subject, 63 | text: emailText, 64 | html: emailBody, 65 | }); 66 | 67 | // __log.debug(`Message sent: ${info.messageId}`); 68 | log.debug(`Preview URL: ${nodemailer.getTestMessageUrl(info)}`); 69 | }; 70 | -------------------------------------------------------------------------------- /src/tasks/generateEntity.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import inquirer from 'inquirer'; 3 | 4 | const CURR_DIR = process.cwd(); 5 | const CHOICES = fs.readdirSync(`${__dirname}/templates`); 6 | 7 | // Create folder based on template picked and name provided 8 | const createEntityFolder = (templatePath: string, newProjectPath: string) => { 9 | const filesToCreate = fs.readdirSync(templatePath); 10 | 11 | filesToCreate.forEach((file) => { 12 | const origFilePath = `${templatePath}/${file}`; 13 | const stats = fs.statSync(origFilePath); 14 | 15 | if (stats.isFile()) { 16 | const contents = fs.readFileSync(origFilePath, 'utf8'); 17 | const writePath = `${newProjectPath}/${file}`; 18 | fs.writeFileSync(writePath, contents, 'utf8'); 19 | } else if (stats.isDirectory()) { 20 | fs.mkdirSync(`${newProjectPath}/${file}`); 21 | createEntityFolder(`${templatePath}/${file}`, `${newProjectPath}/${file}`); 22 | } 23 | }); 24 | }; 25 | 26 | // Replace placeholders from template depending on name provided 27 | const replaceEntityFiles = (newEntityPath: string, entityName: string) => { 28 | const filesToModify = fs.readdirSync(newEntityPath); 29 | 30 | filesToModify.forEach((file) => { 31 | const newFilePath = `${newEntityPath}/${file}`; 32 | const stats = fs.statSync(newFilePath); 33 | 34 | if (stats.isFile()) { 35 | const writePath = `${newEntityPath}/${file}`; 36 | 37 | fs.readFile(writePath, 'utf8', (readError, data) => { 38 | if (readError) { 39 | return console.log(readError); 40 | } 41 | const result = data 42 | .replace(/{{lowercaseName}}/g, entityName.toLowerCase()) 43 | .replace(/{{uppercaseName}}/g, entityName.toUpperCase()) 44 | .replace(/{{capitalizedName}}/g, entityName.toUpperCase().charAt(0) + entityName.toLowerCase().slice(1)); 45 | fs.writeFile(writePath, result, 'utf8', (writeError) => { 46 | if (writeError) { 47 | return console.log(writeError); 48 | } 49 | }); 50 | }); 51 | } 52 | }); 53 | }; 54 | 55 | const QUESTIONS = [ 56 | { 57 | name: 'entity-choice', 58 | type: 'list', 59 | message: 'What entity template would you like to generate?', 60 | choices: CHOICES, 61 | }, 62 | { 63 | name: 'entity-name', 64 | type: 'input', 65 | message: 'Entity name:', 66 | validate(input: string) { 67 | if (/^([A-Za-z\-\\_\d])+$/.test(input)) return true; 68 | return 'Entity name may only include letters, numbers, underscores and hashes.'; 69 | }, 70 | }, 71 | ]; 72 | 73 | inquirer 74 | .prompt(QUESTIONS) 75 | .then((answers) => { 76 | const entityChoice = answers['entity-choice']; 77 | const entityName = answers['entity-name']; 78 | const templatePath = `${__dirname}/templates/${entityChoice}`; 79 | console.log(templatePath); 80 | const entitiesPath = `${CURR_DIR}/src/entities/${entityName.toLowerCase()}`; 81 | fs.mkdirSync(`${entitiesPath}`); 82 | console.log(entitiesPath); 83 | createEntityFolder(templatePath, entitiesPath); 84 | replaceEntityFiles(entitiesPath, entityName.toLowerCase()); 85 | return true; 86 | }) 87 | .catch((error) => { 88 | console.log(error); 89 | }); 90 | -------------------------------------------------------------------------------- /src/tasks/templates/default/constants.ts: -------------------------------------------------------------------------------- 1 | export enum SuccessMessages { 2 | {{uppercaseName}}_CREATED = '{{capitalizedName}} created successfully.', 3 | {{uppercaseName}}_UPDATED = '{{capitalizedName}} updated successfully.', 4 | {{uppercaseName}}_DELETED = '{{capitalizedName}} deleted successfully.', 5 | } 6 | 7 | export enum ErrorMessages { 8 | {{uppercaseName}}S_NOT_FOUND = 'No {{lowercaseName}}s found.', 9 | {{uppercaseName}}_NOT_FOUND = '{{capitalizedName}} was not found.', 10 | } 11 | -------------------------------------------------------------------------------- /src/tasks/templates/default/controller.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from 'express'; 2 | import * as controller from '@services/crud.service'; 3 | 4 | import { catchErrors } from '@helpers/catchErrors'; 5 | import { {{capitalizedName}}Model } from './model'; 6 | import { create{{capitalizedName}}Schema, update{{capitalizedName}}Schema } from './validation'; 7 | import { SuccessMessages, ErrorMessages } from './constants'; 8 | 9 | export const create = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 10 | controller.create(req, res, next, create{{capitalizedName}}Schema, {{capitalizedName}}Model, SuccessMessages.{{uppercaseName}}_CREATED); 11 | }); 12 | 13 | export const getAll = catchErrors(async (_req: Request, res: Response, next: NextFunction) => { 14 | controller.getAll(_req, res, next, {{capitalizedName}}Model, ErrorMessages.{{uppercaseName}}S_NOT_FOUND); 15 | }); 16 | 17 | export const getById = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 18 | controller.getByField(req, res, next, {{capitalizedName}}Model, ErrorMessages.{{uppercaseName}}_NOT_FOUND); 19 | }); 20 | 21 | export const update = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 22 | controller.update( 23 | req, 24 | res, 25 | next, 26 | update{{capitalizedName}}Schema, 27 | {{capitalizedName}}Model, 28 | SuccessMessages.{{uppercaseName}}_UPDATED, 29 | ErrorMessages.{{uppercaseName}}_NOT_FOUND, 30 | ); 31 | }); 32 | 33 | export const remove = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 34 | controller.remove(req, res, next, {{capitalizedName}}Model, SuccessMessages.{{uppercaseName}}_DELETED, ErrorMessages.{{uppercaseName}}_NOT_FOUND); 35 | }); 36 | -------------------------------------------------------------------------------- /src/tasks/templates/default/endpoints.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { is } from '@middlewares/permissions'; 3 | import * as {{lowercaseName}} from './controller'; 4 | 5 | const endpoints = Router(); 6 | 7 | endpoints.post('/', is.Auth, {{lowercaseName}}.create); 8 | endpoints.get('/', is.Auth, {{lowercaseName}}.getAll); 9 | endpoints.get('/:id', is.Auth, {{lowercaseName}}.getById); 10 | endpoints.patch('/:id', is.Auth, {{lowercaseName}}.update); 11 | endpoints.delete('/:id', is.Auth, {{lowercaseName}}.remove); 12 | 13 | export default endpoints; 14 | -------------------------------------------------------------------------------- /src/tasks/templates/default/interface.ts: -------------------------------------------------------------------------------- 1 | export interface {{capitalizedName}}Entity {} 2 | -------------------------------------------------------------------------------- /src/tasks/templates/default/model.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | 3 | import { {{capitalizedName}}Entity } from './interface'; 4 | 5 | const {{capitalizedName}}Schema = new Schema<{{capitalizedName}}Entity>({}, { timestamps: true }); 6 | 7 | export const {{capitalizedName}}Model = model<{{capitalizedName}}Entity>('{{capitalizedName}}', {{capitalizedName}}Schema); 8 | -------------------------------------------------------------------------------- /src/tasks/templates/default/validation.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | export const create{{capitalizedName}}Schema = Joi.object({}); 4 | 5 | export const update{{capitalizedName}}Schema = Joi.object({}); 6 | -------------------------------------------------------------------------------- /src/tasks/templates/user/constants.ts: -------------------------------------------------------------------------------- 1 | export enum SuccessMessages { 2 | {{uppercaseName}}_UPDATED = '{{capitalizedName}} updated successfully.', 3 | {{uppercaseName}}_DELETED = '{{capitalizedName}} deleted successfully.', 4 | } 5 | 6 | export enum ErrorMessages { 7 | {{uppercaseName}}S_NOT_FOUND = 'No {{lowercaseName}}s found.', 8 | {{uppercaseName}}_NOT_FOUND = '{{capitalizedName}} was not found.', 9 | } 10 | 11 | export const SALT_ROUNDS = 12; 12 | -------------------------------------------------------------------------------- /src/tasks/templates/user/controller.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from 'express'; 2 | import * as controller from '@services/crud.service'; 3 | 4 | import { catchErrors } from '@helpers/catchErrors'; 5 | import { {{capitalizedName}}Model } from './model'; 6 | import { {{lowercaseName}}Schema } from './validation'; 7 | import { SuccessMessages, ErrorMessages } from './constants'; 8 | 9 | export const getAll = catchErrors(async (_req: Request, res: Response, next: NextFunction) => { 10 | controller.getAll(_req, res, next, {{capitalizedName}}Model, ErrorMessages.{{uppercaseName}}S_NOT_FOUND); 11 | }); 12 | 13 | export const getById = catchErrors(async (_req: Request, res: Response, next: NextFunction) => { 14 | controller.getByField(_req, res, next, {{capitalizedName}}Model, ErrorMessages.{{uppercaseName}}_NOT_FOUND); 15 | }); 16 | 17 | export const update = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 18 | controller.update(req, res, next, {{lowercaseName}}Schema, {{capitalizedName}}Model, SuccessMessages.{{uppercaseName}}_UPDATED, ErrorMessages.{{uppercaseName}}_NOT_FOUND); 19 | }); 20 | 21 | export const remove = catchErrors(async (req: Request, res: Response, next: NextFunction) => { 22 | controller.remove(req, res, next, {{capitalizedName}}Model, SuccessMessages.{{uppercaseName}}_DELETED, ErrorMessages.{{uppercaseName}}_NOT_FOUND); 23 | }); 24 | -------------------------------------------------------------------------------- /src/tasks/templates/user/endpoints.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { is } from '@middlewares/permissions'; 3 | import * as {{lowercaseName}} from './controller'; 4 | 5 | const endpoints = Router(); 6 | 7 | endpoints.get('/', is.Auth, {{lowercaseName}}.getAll); 8 | endpoints.get('/:id', is.Auth, {{lowercaseName}}.getById); 9 | endpoints.patch('/:id', is.Self, {{lowercaseName}}.update); 10 | endpoints.delete('/:id', is.Admin, {{lowercaseName}}.remove); 11 | 12 | export default endpoints; 13 | -------------------------------------------------------------------------------- /src/tasks/templates/user/interface.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | 3 | export interface {{capitalizedName}}Entity { 4 | email: string; 5 | password: string; 6 | firstname: string; 7 | lastname: string; 8 | role?: Types.ObjectId; 9 | } 10 | -------------------------------------------------------------------------------- /src/tasks/templates/user/model.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | import { hash as bcryptHash, genSalt as bcryptGenSalt } from 'bcryptjs'; 3 | 4 | import { AuthModel } from '@entities/auth/model'; 5 | import type { {{capitalizedName}}Entity } from './interface'; 6 | import { SALT_ROUNDS } from './constants'; 7 | 8 | const {{capitalizedName}}Schema = new Schema<{{capitalizedName}}Entity>( 9 | { 10 | email: { 11 | type: String, 12 | required: true, 13 | unique: true, 14 | }, 15 | password: { 16 | type: String, 17 | required: true, 18 | select: false, 19 | }, 20 | firstname: { 21 | type: String, 22 | required: true, 23 | }, 24 | lastname: { 25 | type: String, 26 | required: true, 27 | }, 28 | role: { 29 | type: Schema.Types.ObjectId, 30 | ref: 'Auth', 31 | }, 32 | }, 33 | { timestamps: true }, 34 | ); 35 | 36 | // Before creating a {{lowercaseName}} 37 | {{capitalizedName}}Schema.pre('save', async function save(next) { 38 | // Only hash password if it has been modified or new 39 | if (!this.isModified('password')) return next(); 40 | // Generate salt and hash password 41 | const salt = await bcryptGenSalt(SALT_ROUNDS); 42 | this.password = await bcryptHash(this.password, salt); 43 | next(); 44 | }); 45 | // After creating a {{lowercaseName}} 46 | {{capitalizedName}}Schema.post('save', async (doc) => { 47 | // Create {{lowercaseName}} in auth collection 48 | await AuthModel.create({ email: doc.email, role: '{{capitalizedName}}' }); 49 | }); 50 | {{capitalizedName}}Schema.post('findOneAndDelete', async (doc) => { 51 | // Delete {{lowercaseName}} from auth collection 52 | await AuthModel.deleteOne({ email: doc.email }); 53 | }); 54 | 55 | export const {{capitalizedName}}Model = model<{{capitalizedName}}Entity>('{{capitalizedName}}', {{capitalizedName}}Schema); 56 | -------------------------------------------------------------------------------- /src/tasks/templates/user/validation.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | export const {{lowercaseName}}Schema = Joi.object({ 4 | firstname: Joi.string().trim(), 5 | lastname: Joi.string().trim(), 6 | }); 7 | -------------------------------------------------------------------------------- /src/types/environment.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | DB_URI: string; 5 | DB_URI_TEST: string; 6 | CLIENT_ORIGIN: string; 7 | PORT: number; 8 | JWT_ACCESS_SECRET: string; 9 | JWT_REFRESH_SECRET: string; 10 | JWT_ACCESS_EXPIRATION: string | number; 11 | JWT_REFRESH_EXPIRATION: string | number; 12 | ADMIN_EMAIL: string; 13 | ADMIN_PASSWORD: string; 14 | NODE_ENV: 'development' | 'production'; 15 | } 16 | } 17 | } 18 | 19 | export {}; 20 | -------------------------------------------------------------------------------- /src/types/express/index.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Express { 2 | interface Request { 3 | user: any; 4 | decoded: any; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.ts", "jest.config.js", "jest.setup.js", "src/**/*.unit.test.ts", "__tests__/**/*.int.test.ts", "ecosystem.config.js"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": ["dom", "es6", "es2017", "es2019", "esnext.asynciterable"], 6 | "skipLibCheck": true, 7 | "sourceMap": true, 8 | "outDir": "./build", 9 | "removeComments": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "strictFunctionTypes": true, 13 | "noImplicitThis": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "allowSyntheticDefaultImports": true, 19 | "esModuleInterop": true, 20 | "emitDecoratorMetadata": true, 21 | "experimentalDecorators": true, 22 | "resolveJsonModule": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "baseUrl": "src", 25 | "paths": { 26 | "@*": ["./*"] 27 | }, 28 | "typeRoots": ["./src/types", "./node_modules/@types"] 29 | }, 30 | "ts-node": { 31 | "swc": true 32 | }, 33 | "exclude": ["node_modules", "./src/bin", "./src/coverage", "./src/build", "./src/dist"], 34 | "include": ["./src/**/*.ts"] 35 | } 36 | --------------------------------------------------------------------------------