├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── auto-merge.yml │ └── test.yml ├── .gitignore ├── .webpack └── bundle.js ├── __mocks__ └── ses.js ├── babel.config.js ├── commitlint.config.js ├── customBabelTransformer.js ├── jest.config.js ├── lerna.json ├── package.json ├── packages ├── api │ ├── .env.example │ ├── babel.config.js │ ├── jest.config.js │ ├── junit.xml │ ├── package.json │ ├── script │ │ └── generateSwagger.ts │ ├── src │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ └── app.spec.ts.snap │ │ │ └── app.spec.ts │ │ ├── api │ │ │ ├── ApiHelpers.ts │ │ │ ├── auth │ │ │ │ └── v1 │ │ │ │ │ └── login │ │ │ │ │ ├── __tests__ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ ├── authEmail.spec.ts.snap │ │ │ │ │ │ └── authPassword.spec.ts.snap │ │ │ │ │ ├── authEmail.spec.ts │ │ │ │ │ └── authPassword.spec.ts │ │ │ │ │ ├── authEmail.ts │ │ │ │ │ ├── authEmail.yml │ │ │ │ │ └── authPassword.ts │ │ │ └── user │ │ │ │ └── v1 │ │ │ │ ├── __tests__ │ │ │ │ ├── userDelete.spec.ts │ │ │ │ ├── userGet.spec.ts │ │ │ │ ├── userGetAll.spec.tsx │ │ │ │ └── userPost.spec.ts │ │ │ │ ├── userDelete.ts │ │ │ │ ├── userGet.ts │ │ │ │ ├── userGetAll.ts │ │ │ │ ├── userPost.ts │ │ │ │ ├── userUpdateOrCreate.ts │ │ │ │ └── userUtils.ts │ │ ├── app.ts │ │ ├── auth │ │ │ ├── __tests__ │ │ │ │ └── getToken.spec.ts │ │ │ ├── auth.ts │ │ │ ├── base64.ts │ │ │ ├── getToken.ts │ │ │ └── sessionManagement.ts │ │ ├── common │ │ │ └── consts.ts │ │ ├── config.ts │ │ ├── database.ts │ │ ├── index.ts │ │ ├── routes.ts │ │ ├── swagger │ │ │ ├── fpApi.json │ │ │ ├── fpApi.yml │ │ │ └── swaggerConfig.js │ │ ├── swaggerSpec.ts │ │ └── types.ts │ ├── test │ │ ├── babel-transformer.js │ │ ├── environment │ │ │ └── mongodb.js │ │ ├── index.ts │ │ ├── restUtils.ts │ │ └── setupTestFramework.js │ └── webpack.config.api.js ├── babel │ ├── index.js │ ├── package.json │ └── tsconfig.json ├── modules │ ├── README.md │ ├── babel.config.js │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── tenant │ │ │ ├── TenantModel.ts │ │ │ └── __fixtures__ │ │ │ │ └── createTenant.ts │ │ └── user │ │ │ ├── UserModel.ts │ │ │ └── __fixtures__ │ │ │ └── createUser.ts │ └── yarn-error.log ├── notification │ ├── .env.example │ ├── README.md │ ├── babel.config.js │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── email │ │ │ ├── __tests__ │ │ │ │ └── sendEmail.spec.ts │ │ │ ├── htmlEmail.tsx │ │ │ └── sendEmail.ts │ │ └── index.ts │ └── test │ │ └── babel-transformer.js ├── server │ ├── .env.example │ ├── babel.config.js │ ├── config.ts │ ├── jest.config.js │ ├── junit.xml │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ └── app-server.spec.ts │ │ ├── app.ts │ │ ├── index.ts │ │ ├── schema │ │ │ ├── QueryType.ts │ │ │ └── schema.ts │ │ └── types.ts │ ├── test │ │ ├── babel-transformer.js │ │ ├── environment │ │ │ └── mongodb.js │ │ ├── index.ts │ │ ├── setup.js │ │ ├── setupTestFramework.js │ │ └── teardown.js │ └── webpack.config.server.js ├── shared │ ├── babel.config.js │ ├── package.json │ └── src │ │ ├── config.ts │ │ ├── index.ts │ │ └── mongo.ts ├── test │ ├── README.md │ ├── babel.config.js │ ├── package.json │ ├── src │ │ ├── babel-transformer.js │ │ ├── clearDatabase.ts │ │ ├── connectMongoose.ts │ │ ├── counters.ts │ │ ├── createResource │ │ │ └── getOrCreate.ts │ │ ├── disconnectMongoose.ts │ │ ├── environment │ │ │ └── mongodb.js │ │ ├── getObjectId.ts │ │ ├── index.ts │ │ ├── setup.js │ │ ├── setupTestFramework.js │ │ └── teardown.js │ └── yarn-error.log └── types │ ├── README.md │ ├── babel.config.js │ ├── package.json │ ├── src │ ├── DeepPartial.ts │ └── index.ts │ └── tsconfig.json ├── readme.md ├── tsconfig.json ├── webpack └── webpack.config.js ├── webpackx.ts ├── yarn-error.log └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # AWS Keys 2 | AWS_REGION= -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .circleci 2 | .github 3 | build 4 | data 5 | digital_assets 6 | flow-typed 7 | hard-source-cache 8 | public 9 | __generated__ 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | parser: '@typescript-eslint/parser', 5 | env: { 6 | browser: true, 7 | node: true, 8 | jest: true, 9 | es6: true, 10 | 'cypress/globals': true, 11 | serviceworker: true, 12 | }, 13 | plugins: ['react', 'flowtype', 'import', 'cypress', 'relay', '@typescript-eslint', 'react-hooks'], 14 | parserOptions: { 15 | ecmaVersion: 10, 16 | sourceType: 'module', 17 | ecmaFeatures: { 18 | modules: true, 19 | }, 20 | }, 21 | extends: [ 22 | 'eslint:recommended', 23 | 'plugin:react/recommended', 24 | 'plugin:import/errors', 25 | 'plugin:relay/recommended', 26 | 'plugin:@typescript-eslint/eslint-recommended', 27 | 'plugin:@typescript-eslint/recommended', 28 | 'prettier/@typescript-eslint', 29 | ], 30 | rules: { 31 | 'comma-dangle': [2, 'always-multiline'], 32 | quotes: [2, 'single', { allowTemplateLiterals: true, avoidEscape: true }], 33 | 'jsx-quotes': [2, 'prefer-single'], 34 | 'react/prop-types': 0, 35 | 'no-case-declarations': 0, 36 | 'react/jsx-no-bind': 0, 37 | 'react/display-name': 0, 38 | 'new-cap': 0, 39 | 'no-unexpected-multiline': 0, 40 | 'no-class-assign': 1, 41 | 'no-console': 2, 42 | 'object-curly-spacing': [1, 'always'], 43 | 'import/first': 2, 44 | 'import/default': 0, 45 | 'no-unused-vars': ['error', { ignoreRestSiblings: true }], 46 | 'no-extra-boolean-cast': 0, 47 | 'import/named': 0, 48 | 'import/namespace': [2, { allowComputed: true }], 49 | 'import/no-duplicates': 2, 50 | 'import/order': [2, { 'newlines-between': 'always-and-inside-groups' }], 51 | 'react/no-children-prop': 1, 52 | 'react/no-deprecated': 1, 53 | 'import/no-cycle': 1, 54 | 'import/no-self-import': 1, 55 | 'relay/graphql-syntax': 'error', 56 | 'relay/compat-uses-vars': 'warn', 57 | 'relay/graphql-naming': 'error', 58 | 'relay/generated-flow-types': 'warn', 59 | 'relay/no-future-added-value': 'warn', 60 | 'relay/unused-fields': 0, 61 | indent: 0, 62 | '@typescript-eslint/indent': 0, 63 | '@typescript-eslint/camelcase': 0, 64 | '@typescript-eslint/explicit-function-return-type': 0, 65 | 'interface-over-type-literal': 0, 66 | '@typescript-eslint/consistent-type-definitions': 0, 67 | '@typescript-eslint/prefer-interface': 0, 68 | 'lines-between-class-members': 0, 69 | '@typescript-eslint/explicit-member-accessibility': 0, 70 | '@typescript-eslint/no-non-null-assertion': 0, 71 | '@typescript-eslint/no-unused-vars': [ 72 | 'error', 73 | { 74 | ignoreRestSiblings: true, 75 | }, 76 | ], 77 | '@typescript-eslint/no-var-requires': 1, 78 | 'react-hooks/rules-of-hooks': 'error', 79 | // disable as it can cause more harm than good 80 | //'react-hooks/exhaustive-deps': 'warn', 81 | '@typescript-eslint/no-empty-function': 1, 82 | '@typescript-eslint/interface-name-prefix': 0, 83 | }, 84 | settings: { 85 | 'import/resolver': { 86 | node: true, 87 | 'eslint-import-resolver-typescript': true, 88 | 'eslint-import-resolver-lerna': { 89 | packages: path.resolve(__dirname, 'packages'), 90 | }, 91 | }, 92 | }, 93 | }; 94 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | schema.json linguist-generated 2 | *.json merge=json 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: daniloab 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "01:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: auto-merge 2 | 3 | on: 4 | pull_request_target: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | auto-merge: 10 | runs-on: ubuntu-latest 11 | if: github.actor == 'dependabot[bot]' 12 | steps: 13 | - uses: ahmadnassri/action-dependabot-auto-merge@v2.4 14 | with: 15 | github-token: ${{ secrets.AUTOMERGE_TOKEN }} 16 | command: "squash and merge" 17 | target: minor 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: "14" 21 | - run: yarn 22 | - run: yarn jest 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | node_modules 4 | .env 5 | 6 | build 7 | dist 8 | 9 | graphql.*.json 10 | 11 | # Random things to ignore 12 | ignore/ 13 | package-lock.json 14 | /yarn-offline-cache 15 | .cache -------------------------------------------------------------------------------- /.webpack/bundle.js: -------------------------------------------------------------------------------- 1 | module.exports = /******/ (function (modules) { 2 | // webpackBootstrap 3 | /******/ // The module cache 4 | /******/ var installedModules = {}; // The require function 5 | /******/ 6 | /******/ /******/ function __webpack_require__(moduleId) { 7 | /******/ 8 | /******/ // Check if module is in cache 9 | /******/ if (installedModules[moduleId]) { 10 | /******/ return installedModules[moduleId].exports; 11 | /******/ 12 | } // Create a new module (and put it into the cache) 13 | /******/ /******/ var module = (installedModules[moduleId] = { 14 | /******/ i: moduleId, 15 | /******/ l: false, 16 | /******/ exports: {}, 17 | /******/ 18 | }); // Execute the module function 19 | /******/ 20 | /******/ /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); // Flag the module as loaded 21 | /******/ 22 | /******/ /******/ module.l = true; // Return the exports of the module 23 | /******/ 24 | /******/ /******/ return module.exports; 25 | /******/ 26 | } // expose the modules object (__webpack_modules__) 27 | /******/ 28 | /******/ 29 | /******/ /******/ __webpack_require__.m = modules; // expose the module cache 30 | /******/ 31 | /******/ /******/ __webpack_require__.c = installedModules; // define getter function for harmony exports 32 | /******/ 33 | /******/ /******/ __webpack_require__.d = function (exports, name, getter) { 34 | /******/ if (!__webpack_require__.o(exports, name)) { 35 | /******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); 36 | /******/ 37 | } 38 | /******/ 39 | }; // define __esModule on exports 40 | /******/ 41 | /******/ /******/ __webpack_require__.r = function (exports) { 42 | /******/ if (typeof Symbol !== 'undefined' && Symbol.toStringTag) { 43 | /******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); 44 | /******/ 45 | } 46 | /******/ Object.defineProperty(exports, '__esModule', { value: true }); 47 | /******/ 48 | }; // create a fake namespace object // mode & 1: value is a module id, require it // mode & 2: merge all properties of value into the ns // mode & 4: return value when already ns object // mode & 8|1: behave like require 49 | /******/ 50 | /******/ /******/ /******/ /******/ /******/ /******/ __webpack_require__.t = function (value, mode) { 51 | /******/ if (mode & 1) value = __webpack_require__(value); 52 | /******/ if (mode & 8) return value; 53 | /******/ if (mode & 4 && typeof value === 'object' && value && value.__esModule) return value; 54 | /******/ var ns = Object.create(null); 55 | /******/ __webpack_require__.r(ns); 56 | /******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); 57 | /******/ if (mode & 2 && typeof value != 'string') 58 | for (var key in value) 59 | __webpack_require__.d( 60 | ns, 61 | key, 62 | function (key) { 63 | return value[key]; 64 | }.bind(null, key), 65 | ); 66 | /******/ return ns; 67 | /******/ 68 | }; // getDefaultExport function for compatibility with non-harmony modules 69 | /******/ 70 | /******/ /******/ __webpack_require__.n = function (module) { 71 | /******/ var getter = 72 | module && module.__esModule 73 | ? /******/ function getDefault() { 74 | return module['default']; 75 | } 76 | : /******/ function getModuleExports() { 77 | return module; 78 | }; 79 | /******/ __webpack_require__.d(getter, 'a', getter); 80 | /******/ return getter; 81 | /******/ 82 | }; // Object.prototype.hasOwnProperty.call 83 | /******/ 84 | /******/ /******/ __webpack_require__.o = function (object, property) { 85 | return Object.prototype.hasOwnProperty.call(object, property); 86 | }; // __webpack_public_path__ 87 | /******/ 88 | /******/ /******/ __webpack_require__.p = ''; // Load entry module and return exports 89 | /******/ 90 | /******/ 91 | /******/ /******/ return __webpack_require__((__webpack_require__.s = './packages/api/script/generateSwagger.ts')); 92 | /******/ 93 | })( 94 | /************************************************************************/ 95 | /******/ { 96 | /***/ './packages/api/script/generateSwagger.ts': 97 | /*!************************************************!*\ 98 | !*** ./packages/api/script/generateSwagger.ts ***! 99 | \************************************************/ 100 | /*! exports provided: onExit */ 101 | /***/ function (module, __webpack_exports__, __webpack_require__) { 102 | 'use strict'; 103 | __webpack_require__.r(__webpack_exports__); 104 | /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, 'onExit', function () { 105 | return onExit; 106 | }); 107 | /* harmony import */ var child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__( 108 | /*! child_process */ 'child_process', 109 | ); 110 | /* harmony import */ var child_process__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/ __webpack_require__.n( 111 | child_process__WEBPACK_IMPORTED_MODULE_0__, 112 | ); 113 | /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! path */ 'path'); 114 | /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/ __webpack_require__.n( 115 | path__WEBPACK_IMPORTED_MODULE_1__, 116 | ); 117 | 118 | const cwd = process.cwd(); 119 | function onExit(childProcess) { 120 | return new Promise((resolve, reject) => { 121 | childProcess.once('exit', (code) => { 122 | if (code === 0) { 123 | resolve(undefined); 124 | } else { 125 | reject(new Error(`Exit with error code: ${code}`)); 126 | } 127 | }); 128 | childProcess.once('error', (err) => { 129 | reject(err); 130 | }); 131 | }); 132 | } 133 | 134 | const runProcess = async (command, args, cwdCommand) => { 135 | const childProcess = Object(child_process__WEBPACK_IMPORTED_MODULE_0__['spawn'])(command, args, { 136 | stdio: [process.stdin, process.stdout, process.stderr], 137 | cwd: cwdCommand, 138 | }); 139 | await onExit(childProcess); 140 | }; 141 | 142 | const generateSwagger = async () => { 143 | const command = 'yarn'; 144 | const apiPackage = './packages/api'; 145 | const swaggerConfig = './src/swagger/swaggerConfig.js'; 146 | const apiPath = path__WEBPACK_IMPORTED_MODULE_1___default.a.join(cwd, apiPackage); 147 | const swaggerConfigPath = path__WEBPACK_IMPORTED_MODULE_1___default.a.join(apiPath, swaggerConfig); 148 | const authPath = path__WEBPACK_IMPORTED_MODULE_1___default.a.join(cwd, './packages/api/src/api/auth/v1'); 149 | const authRegex = `${authPath}/**/*.yml`; 150 | console.log(' authRegex', authRegex); 151 | const args = ['swagger-jsdoc', '-d', swaggerConfigPath, authRegex, '-o']; 152 | const argsYml = [...args, './src/swagger/fpApi.yml']; 153 | const argsJson = [...args, './src/swagger/fpApi.json']; 154 | await runProcess(command, argsYml, apiPath); 155 | await runProcess(command, argsJson, apiPath); 156 | }; 157 | 158 | (async () => { 159 | try { 160 | await generateSwagger(); 161 | } catch (err) { 162 | // eslint-disable-next-line 163 | console.log('err: ', err); 164 | } 165 | 166 | process.exit(0); 167 | })(); 168 | 169 | /***/ 170 | }, 171 | 172 | /***/ child_process: 173 | /*!********************************!*\ 174 | !*** external "child_process" ***! 175 | \********************************/ 176 | /*! no static exports found */ 177 | /***/ function (module, exports) { 178 | module.exports = require('child_process'); 179 | 180 | /***/ 181 | }, 182 | 183 | /***/ path: 184 | /*!***********************!*\ 185 | !*** external "path" ***! 186 | \***********************/ 187 | /*! no static exports found */ 188 | /***/ function (module, exports) { 189 | module.exports = require('path'); 190 | 191 | /***/ 192 | }, 193 | 194 | /******/ 195 | }, 196 | ); 197 | -------------------------------------------------------------------------------- /__mocks__/ses.js: -------------------------------------------------------------------------------- 1 | const SES = jest.fn(() => ({ 2 | sendEmail: jest.fn(() => ({ 3 | promise: () => new Promise(res => res(true)), 4 | })), 5 | sendRawEmail: jest.fn(() => ({ 6 | promise: () => new Promise(res => res(true)), 7 | })), 8 | })); 9 | 10 | export default SES; 11 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const { workspaces = [] } = require('./package.json'); 2 | 3 | module.exports = { 4 | babelrcRoots: ['.', ...(workspaces.packages || workspaces)], 5 | presets: [ 6 | '@babel/preset-flow', 7 | [ 8 | '@babel/preset-env', 9 | { 10 | targets: { 11 | node: 'current', 12 | }, 13 | }, 14 | ], 15 | '@babel/preset-react', 16 | '@babel/preset-typescript', 17 | ], 18 | plugins: [ 19 | '@babel/plugin-proposal-class-properties', 20 | '@babel/plugin-proposal-export-default-from', 21 | '@babel/plugin-proposal-export-namespace-from', 22 | '@babel/plugin-proposal-nullish-coalescing-operator', 23 | '@babel/plugin-proposal-optional-chaining', 24 | ], 25 | env: { 26 | test: { 27 | plugins: ['require-context-hook'], 28 | }, 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'body-leading-blank': [1, 'always'], 4 | 'footer-leading-blank': [1, 'always'], 5 | 'header-max-length': [2, 'always', 100], 6 | 'scope-case': [2, 'always', 'lower-case'], 7 | 'subject-case': [2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']], 8 | 'subject-empty': [2, 'never'], 9 | 'subject-full-stop': [2, 'never', '.'], 10 | 'type-case': [2, 'always', 'lower-case'], 11 | 'type-empty': [2, 'never'], 12 | 'type-enum': [ 13 | 2, 14 | 'always', 15 | ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test', 'i18n'], 16 | ], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /customBabelTransformer.js: -------------------------------------------------------------------------------- 1 | const babelJest = require('babel-jest'); 2 | const entriaBabel = require('@reat-api/babel'); 3 | 4 | module.exports = babelJest.createTransformer(entriaBabel); 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: [ 3 | '/packages/api/jest.config.js', 4 | '/packages/server/jest.config.js', 5 | '/packages/notification/jest.config.js', 6 | ], 7 | transform: { 8 | '^.+\\.(js|ts|tsx)?$': require('path').resolve('./customBabelTransformer'), 9 | }, 10 | moduleFileExtensions: ['js', 'css', 'ts', 'tsx'], 11 | }; 12 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "version": "1.0.0", 6 | "npmClient": "yarn", 7 | "useWorkspaces": true 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fp", 3 | "version": "1.0.0", 4 | "description": "rest api with koa js", 5 | "main": "index.js", 6 | "private": true, 7 | "author": "Danilo Assis", 8 | "license": "MIT", 9 | "devDependencies": { 10 | "@babel/cli": "7.14.3", 11 | "@babel/core": "7.14.0", 12 | "@babel/node": "7.14.9", 13 | "@babel/plugin-proposal-class-properties": "7.13.0", 14 | "@babel/plugin-proposal-export-default-from": "7.12.13", 15 | "@babel/plugin-proposal-export-namespace-from": "7.12.13", 16 | "@babel/plugin-transform-flow-strip-types": "7.16.7", 17 | "@babel/polyfill": "7.12.1", 18 | "@babel/preset-env": "7.14.1", 19 | "@babel/preset-flow": "7.13.13", 20 | "@babel/preset-react": "7.16.7", 21 | "@babel/preset-typescript": "7.7.7", 22 | "@babel/register": "7.14.5", 23 | "@types/babel__core": "7.1.18", 24 | "@types/babel__generator": "7.6.2", 25 | "@types/babel__template": "7.4.0", 26 | "@types/babel__traverse": "7.11.1", 27 | "@types/bcryptjs": "^2.4.1", 28 | "@types/dotenv-safe": "^8.1.1", 29 | "@types/graphql": "^14.2.2", 30 | "@types/graphql-relay": "^0.6.0", 31 | "@types/koa": "^2.0.46", 32 | "@types/koa-bodyparser": "^5.0.1", 33 | "@types/koa-convert": "^1.2.0", 34 | "@types/koa-cors": "^0.0.2", 35 | "@types/koa-logger": "^3.1.0", 36 | "@types/koa-multer": "^1.0.0", 37 | "@types/koa-router": "^7.0.37", 38 | "@types/mongoose": "^5.3.7", 39 | "@types/node": "^16.4.11", 40 | "@typescript-eslint/eslint-plugin": "2.34.0", 41 | "@typescript-eslint/parser": "2.34.0", 42 | "babel-core": "^7.0.0-bridge.0", 43 | "babel-eslint": "10.1.0", 44 | "babel-jest": "^26.0.1", 45 | "babel-loader": "^8.0.5", 46 | "babel-plugin-require-context-hook": "^1.0.0", 47 | "babel-watch": "^7.4.1", 48 | "eslint": "6.8.0", 49 | "eslint-config-airbnb": "18.2.1", 50 | "eslint-config-okonet": "7.0.2", 51 | "eslint-config-prettier": "^6.0.0", 52 | "eslint-config-shellscape": "3.0.0", 53 | "eslint-import-resolver-lerna": "^1.1.0", 54 | "eslint-import-resolver-typescript": "2.4.0", 55 | "eslint-import-resolver-webpack": "0.13.1", 56 | "eslint-plugin-cypress": "2.11.3", 57 | "eslint-plugin-flowtype": "4.7.0", 58 | "eslint-plugin-import": "2.23.4", 59 | "eslint-plugin-jsx-a11y": "6.4.1", 60 | "eslint-plugin-node": "11.1.0", 61 | "eslint-plugin-prettier": "^3.1.0", 62 | "eslint-plugin-react": "7.23.2", 63 | "eslint-plugin-react-hooks": "4.2.0", 64 | "eslint-plugin-relay": "1.3.2", 65 | "eslint-plugin-typescript": "0.14.0", 66 | "jest": "26.6.3", 67 | "jest-junit": "12.0.0", 68 | "jsonwebtoken": "^8.5.1", 69 | "lerna": "^4.0.0", 70 | "lint-staged": "^11.0.0", 71 | "node-dev": "^7.0.0", 72 | "nodemon": "1.18.9", 73 | "pre-commit": "^1.2.2", 74 | "prettier": "2.3.1", 75 | "typescript": "4.2.4" 76 | }, 77 | "pre-commit": "lint:staged", 78 | "lint-staged": { 79 | "*.{js,ts,tsx}": [ 80 | "yarn prettier", 81 | "eslint --fix", 82 | "git add" 83 | ], 84 | "*.yml": [ 85 | "prettier --write", 86 | "git add" 87 | ] 88 | }, 89 | "workspaces": { 90 | "packages": [ 91 | "packages/*" 92 | ] 93 | }, 94 | "scripts": { 95 | "api": "yarn workspace @fp/api start", 96 | "api:swagger": "yarn w ./packages/api/script/generateSwagger.ts", 97 | "b": "babel-node --extensions \".es6,.js,.es,.jsx,.mjs,.ts,.tsx\"", 98 | "build": "yarn build:modules && yarn build:shared && yarn build:types && yarn build:test", 99 | "build:modules": "yarn workspace @fp/modules build", 100 | "build:shared": "yarn workspace @fp/shared build", 101 | "build:types": "yarn workspace @fp/types build", 102 | "build:test": "yarn workspace @fp/test build", 103 | "jest": "jest", 104 | "lint:staged": "lint-staged", 105 | "prettier": "prettier --write --single-quote true --trailing-comma all --print-width 120", 106 | "server": "yarn workspace @fp/server start", 107 | "w": "yarn b webpackx.ts" 108 | }, 109 | "dependencies": { 110 | "bcryptjs": "^2.4.3", 111 | "jest-environment-node": "^26.0.1", 112 | "jest-fetch-mock": "^3.0.3", 113 | "mongodb-memory-server-global": "^6.6.1", 114 | "supertest": "^6.1.3" 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /packages/api/.env.example: -------------------------------------------------------------------------------- 1 | MONGO_URI= 2 | API_PORT= 3 | JWT_KEY= -------------------------------------------------------------------------------- /packages/api/babel.config.js: -------------------------------------------------------------------------------- 1 | const config = require('@fp/babel'); 2 | 3 | module.exports = config; 4 | -------------------------------------------------------------------------------- /packages/api/jest.config.js: -------------------------------------------------------------------------------- 1 | const pack = require('./package'); 2 | 3 | module.exports = { 4 | displayName: pack.name, 5 | name: pack.name, 6 | testEnvironment: '/test/environment/mongodb', 7 | testPathIgnorePatterns: ['/node_modules/', './dist'], 8 | coverageReporters: ['lcov', 'html'], 9 | setupFilesAfterEnv: ['/test/setupTestFramework.js'], 10 | resetModules: false, 11 | reporters: ['default', 'jest-junit'], 12 | transform: { 13 | '^.+\\.(js|ts|tsx)?$': '/test/babel-transformer', 14 | }, 15 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(js|ts|tsx)?$', 16 | moduleFileExtensions: ['ts', 'js', 'tsx', 'json'], 17 | }; 18 | -------------------------------------------------------------------------------- /packages/api/junit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Error: expect(received).toBe(expected) // Object.is equality 14 | 15 | Expected: "Unknown error processing object" 16 | Received: "Invalid id" 17 | at Object.<anonymous> (/Users/entria/Documents/projects/fullstack-playground/packages/api/src/api/user/v1/__tests__/userPost.spec.ts:80:33) 18 | at processTicksAndRejections (internal/process/task_queues.js:97:5) 19 | 20 | 21 | Error: expect(received).toBe(expected) // Object.is equality 22 | 23 | Expected: "User not found" 24 | Received: "Invalid id" 25 | at Object.<anonymous> (/Users/entria/Documents/projects/fullstack-playground/packages/api/src/api/user/v1/__tests__/userPost.spec.ts:99:33) 26 | at processTicksAndRejections (internal/process/task_queues.js:97:5) 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /packages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fp/api", 3 | "version": "1.0.0", 4 | "description": "api package", 5 | "main": "index.js", 6 | "author": "Danilo Assis", 7 | "license": "MIT", 8 | "private": true, 9 | "dependencies": { 10 | "@babel/polyfill": "^7.8.7", 11 | "dotenv-safe": "^8.2.0", 12 | "graphql-relay": "^0.6.0", 13 | "isomorphic-fetch": "^3.0.0", 14 | "koa": "^2.12.0", 15 | "koa-bodyparser": "^4.3.0", 16 | "koa-cors": "^0.0.16", 17 | "koa-logger": "^3.2.1", 18 | "koa-multer": "^1.0.2", 19 | "koa-router": "^10.0.0", 20 | "mongoose": "^5.9.14", 21 | "swagger-jsdoc": "^6.1.0", 22 | "yup": "^0.29.1" 23 | }, 24 | "devDependencies": { 25 | "@fp/babel": "*", 26 | "@fp/modules": "*", 27 | "babel-loader": "^8.0.6", 28 | "reload-server-webpack-plugin": "^1.0.1", 29 | "webpack": "4.46.0", 30 | "webpack-cli": "4.7.2", 31 | "webpack-node-externals": "3.0.0", 32 | "webpack-plugin-serve": "1.4.1" 33 | }, 34 | "scripts": { 35 | "start": "webpack --watch --progress --config webpack.config.api.js" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/api/script/generateSwagger.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess, spawn } from 'child_process'; 2 | import path from 'path'; 3 | 4 | const cwd = process.cwd(); 5 | 6 | export function onExit(childProcess: ChildProcess): Promise { 7 | return new Promise((resolve, reject) => { 8 | childProcess.once('exit', (code: number) => { 9 | if (code === 0) { 10 | resolve(undefined); 11 | } else { 12 | reject(new Error(`Exit with error code: ${code}`)); 13 | } 14 | }); 15 | childProcess.once('error', (err: Error) => { 16 | reject(err); 17 | }); 18 | }); 19 | } 20 | 21 | const runProcess = async (command: string, args: string[], cwdCommand: string) => { 22 | const childProcess = spawn(command, args, { 23 | stdio: [process.stdin, process.stdout, process.stderr], 24 | cwd: cwdCommand, 25 | }); 26 | 27 | await onExit(childProcess); 28 | }; 29 | 30 | const generateSwagger = async () => { 31 | const command = 'yarn'; 32 | 33 | const apiPackage = './packages/api'; 34 | const swaggerConfig = './src/swagger/swaggerConfig.js'; 35 | 36 | const apiPath = path.join(cwd, apiPackage); 37 | 38 | const swaggerConfigPath = path.join(apiPath, swaggerConfig); 39 | 40 | const authPath = path.join(cwd, './packages/api/src/api/auth/v1'); 41 | const authRegex = `${authPath}/**/*.yml`; 42 | 43 | const args = ['swagger-jsdoc', '-d', swaggerConfigPath, authRegex, '-o']; 44 | 45 | const argsYml = [...args, './src/swagger/fpApi.yml']; 46 | 47 | const argsJson = [...args, './src/swagger/fpApi.json']; 48 | 49 | await runProcess(command, argsYml, apiPath); 50 | await runProcess(command, argsJson, apiPath); 51 | }; 52 | 53 | (async () => { 54 | try { 55 | await generateSwagger(); 56 | } catch (err) { 57 | // eslint-disable-next-line 58 | console.log('err: ', err); 59 | } 60 | 61 | process.exit(0); 62 | })(); 63 | -------------------------------------------------------------------------------- /packages/api/src/__tests__/__snapshots__/app.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should get api version correctly 1`] = ` 4 | Object { 5 | "message": "1.0.0", 6 | "status": "OK", 7 | } 8 | `; 9 | -------------------------------------------------------------------------------- /packages/api/src/__tests__/app.spec.ts: -------------------------------------------------------------------------------- 1 | import { clearDbAndRestartCounters, connectMongoose, disconnectMongoose } from '@fp/test'; 2 | 3 | import { createGetApiCall } from '../../test'; 4 | 5 | beforeAll(connectMongoose); 6 | 7 | beforeEach(clearDbAndRestartCounters); 8 | 9 | afterAll(disconnectMongoose); 10 | 11 | const url = '/api/version'; 12 | 13 | it('should get api version correctly', async () => { 14 | const response = await createGetApiCall({ url }); 15 | 16 | expect(response.body).toMatchSnapshot(); 17 | expect(response.status).toBe(200); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/api/src/api/ApiHelpers.ts: -------------------------------------------------------------------------------- 1 | import { Model, Types } from 'mongoose'; 2 | import { Context } from 'koa'; 3 | 4 | export const MESSAGE = { 5 | AUTH: { 6 | UNAUTHORIZED: 'Unauthorized', 7 | }, 8 | COMMON: { 9 | INVALID_ID: 'Invalid id', 10 | EMAIL: 'email is a required field', 11 | EMAIL_INVALID: 'email must be a valid email', 12 | YUP_VALIDATION: 'Unknown error processing object', 13 | }, 14 | LOGIN: { 15 | EMAIL_SUCCESS: 'Email successful', 16 | INCORRECT: 'Email or password incorrect', 17 | INVALID_LOGIN: 'Invalid login', 18 | INVALID_TENANT: 'Invalid tenant', 19 | SUCCESS: 'Login successful', 20 | }, 21 | PAGE_INFO: { 22 | ERRORS: { 23 | NEGATIVE: 'Pagination values should be positive values', 24 | }, 25 | }, 26 | TENANT: { 27 | NOT_FOUND: 'Tenant not found', 28 | }, 29 | USER: { 30 | CREATING: 'Some error occurred while creating user', 31 | MISSING: 'Missing user', 32 | NOT_FOUND: 'User not found', 33 | PASSWORD: 'You must informa a password', 34 | PRIMARY_KEY: 'User should have one of primary keys: id, or email', 35 | UPDATING: 'Some error occurred while updating user', 36 | }, 37 | }; 38 | 39 | export const getSkipAndLimit = (ctx: Context) => { 40 | const { skip = 0, limit = 100 } = ctx.query; 41 | 42 | if (skip < 0 || limit < 0) { 43 | return { 44 | skip: null, 45 | limit: null, 46 | errors: [{ data: { skip, limit }, message: MESSAGE.PAGE_INFO.ERRORS.NEGATIVE }], 47 | }; 48 | } 49 | 50 | const mongoLimit = Math.min(parseInt(limit, 10), 100); 51 | const mongoSkip = parseInt(skip, 10); 52 | 53 | return { 54 | skip: mongoSkip, 55 | limit: mongoLimit, 56 | errors: null, 57 | }; 58 | }; 59 | 60 | type ErrorValidate = { 61 | data: {}; 62 | message: string; 63 | }; 64 | 65 | type PageInfo = { 66 | errors?: ErrorValidate[]; 67 | skip: number; 68 | limit: number; 69 | totalCount: number; 70 | hasPreviousPage: number; 71 | hasNextPage: number; 72 | }; 73 | 74 | export const getPageInfo = async (ctx: Context, model: Model): PageInfo => { 75 | const { company } = ctx; 76 | const { skip, limit, errors } = getSkipAndLimit(ctx); 77 | 78 | if (errors) { 79 | return { 80 | errors, 81 | skip, 82 | limit, 83 | totalCount: null, 84 | hasPreviousPage: null, 85 | hasNextPage: null, 86 | }; 87 | } 88 | 89 | const conditionsTotalCount = { 90 | company, 91 | removedAt: null, 92 | }; 93 | 94 | const totalCount = await model.count(conditionsTotalCount); 95 | 96 | const hasPreviousPage = skip > 0; 97 | const hasNextPage = skip + limit < totalCount; 98 | 99 | return { 100 | skip, 101 | limit, 102 | totalCount, 103 | hasPreviousPage, 104 | hasNextPage, 105 | }; 106 | }; 107 | 108 | export const checkObjectId = id => { 109 | if (!Types.ObjectId.isValid(id)) { 110 | return { 111 | error: true, 112 | }; 113 | } 114 | 115 | return { 116 | error: false, 117 | _id: id, 118 | }; 119 | }; 120 | -------------------------------------------------------------------------------- /packages/api/src/api/auth/v1/login/__tests__/__snapshots__/authEmail.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should return error if domain not found 1`] = ` 4 | Object { 5 | "message": "Tenant not found", 6 | "status": "ERROR", 7 | } 8 | `; 9 | 10 | exports[`should return error if tenant is inactivated 1`] = ` 11 | Object { 12 | "message": "Tenant not found", 13 | "status": "ERROR", 14 | } 15 | `; 16 | 17 | exports[`should return error if user not found 1`] = ` 18 | Object { 19 | "message": "Email or password incorrect", 20 | "status": "ERROR", 21 | } 22 | `; 23 | 24 | exports[`should return success if user found 1`] = ` 25 | Object { 26 | "message": "Email successful", 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /packages/api/src/api/auth/v1/login/__tests__/__snapshots__/authPassword.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should made login with infos correct 1`] = ` 4 | Object { 5 | "message": "Login successful", 6 | "token": "JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0ZW5hbnQiOiI2MGI1YTVlYWZlMzQwNmUwZmFmZjA0YmQiLCJ1c2VyIjoiNjBiNWE1ZWFmZTM0MDZlMGZhZmYwNGJlIiwiaWF0IjoxNjIyNTE3MjI2fQ.zFKHl6UZwXRsuqXo9im3z1YmrKJY8ghhRjLWScGZp-Y", 7 | } 8 | `; 9 | 10 | exports[`should return error if any payload field is missing (email) 1`] = ` 11 | Object { 12 | "message": "Invalid login", 13 | "status": "ERROR", 14 | "user": null, 15 | } 16 | `; 17 | 18 | exports[`should return error if tenant id is different of tenant already checked by auth 1`] = ` 19 | Object { 20 | "message": "Invalid tenant", 21 | "status": "ERROR", 22 | "user": null, 23 | } 24 | `; 25 | 26 | exports[`should return error if user dont exist 1`] = ` 27 | Object { 28 | "message": "Email or password incorrect", 29 | "status": "ERROR", 30 | "user": null, 31 | } 32 | `; 33 | 34 | exports[`should return error if user password is wrong 1`] = ` 35 | Object { 36 | "message": "Email or password incorrect", 37 | "status": "ERROR", 38 | "user": null, 39 | } 40 | `; 41 | -------------------------------------------------------------------------------- /packages/api/src/api/auth/v1/login/__tests__/authEmail.spec.ts: -------------------------------------------------------------------------------- 1 | import { clearDbAndRestartCounters, connectMongoose, disconnectMongoose } from '@fp/test'; 2 | import { createTenant, createUser } from '@fp/modules'; 3 | 4 | import { createApiCall } from '../../../../../../test'; 5 | import { MESSAGE } from '../../../../ApiHelpers'; 6 | 7 | beforeAll(connectMongoose); 8 | 9 | beforeEach(clearDbAndRestartCounters); 10 | 11 | afterAll(disconnectMongoose); 12 | 13 | const url = '/api/auth/v1/login/email'; 14 | 15 | it('should return error if domain not found', async () => { 16 | const payload = { 17 | email: 'test@test.com', 18 | }; 19 | 20 | const response = await createApiCall({ url, payload, domainname: 'test' }); 21 | 22 | expect(response.body).toMatchSnapshot(); 23 | expect(response.body.message).toBe(MESSAGE.TENANT.NOT_FOUND); 24 | expect(response.status).toBe(401); 25 | }); 26 | 27 | it('should return error if tenant is inactivated', async () => { 28 | const payload = { 29 | email: 'test@test.com', 30 | }; 31 | 32 | const response = await createApiCall({ url, payload, domainname: 'test' }); 33 | 34 | expect(response.body).toMatchSnapshot(); 35 | expect(response.body.message).toBe(MESSAGE.TENANT.NOT_FOUND); 36 | expect(response.status).toBe(401); 37 | }); 38 | 39 | it('should return error if user not found', async () => { 40 | const tenant = await createTenant(); 41 | const payload = { 42 | email: 'test@test.com', 43 | }; 44 | 45 | const response = await createApiCall({ 46 | url, 47 | payload, 48 | domainname: tenant.domainName, 49 | }); 50 | 51 | expect(response.body).toMatchSnapshot(); 52 | expect(response.body.message).toBe(MESSAGE.LOGIN.INCORRECT); 53 | expect(response.status).toBe(400); 54 | }); 55 | 56 | it('should return success if user found', async () => { 57 | const tenant = await createTenant(); 58 | const user = await createUser({ email: 'user@test.com', tenant }); 59 | 60 | const payload = { 61 | email: user.email, 62 | }; 63 | 64 | const response = await createApiCall({ 65 | url, 66 | payload, 67 | domainname: tenant.domainName, 68 | }); 69 | 70 | const { companyId, ...restBody } = response.body; 71 | 72 | expect(restBody).toMatchSnapshot(); 73 | expect(response.body.message).toBe(MESSAGE.LOGIN.EMAIL_SUCCESS); 74 | expect(response.status).toBe(200); 75 | }); 76 | -------------------------------------------------------------------------------- /packages/api/src/api/auth/v1/login/__tests__/authPassword.spec.ts: -------------------------------------------------------------------------------- 1 | import { clearDbAndRestartCounters, connectMongoose, disconnectMongoose } from '@fp/test'; 2 | import { createTenant, createUser } from '@fp/modules'; 3 | 4 | import { createApiCall } from '../../../../../../test'; 5 | import { MESSAGE } from '../../../../ApiHelpers'; 6 | 7 | beforeAll(connectMongoose); 8 | 9 | beforeEach(clearDbAndRestartCounters); 10 | 11 | afterAll(disconnectMongoose); 12 | 13 | const url = '/api/auth/v1/login/password'; 14 | 15 | it('should return error if tenant id is different of tenant already checked by auth', async () => { 16 | const tenant = await createTenant(); 17 | const payload = { 18 | email: 'test@test.com', 19 | password: '123456', 20 | tenantId: 'tenantWrongId', 21 | }; 22 | 23 | const response = await createApiCall({ 24 | url, 25 | payload, 26 | domainname: tenant.domainName, 27 | }); 28 | 29 | expect(response.body).toMatchSnapshot(); 30 | expect(response.body.message).toBe(MESSAGE.LOGIN.INVALID_TENANT); 31 | expect(response.status).toBe(401); 32 | }); 33 | 34 | it('should return error if any payload field is missing (email)', async () => { 35 | const tenant = await createTenant(); 36 | const payload = { 37 | password: '123456', 38 | tenantId: tenant._id, 39 | }; 40 | 41 | const response = await createApiCall({ 42 | url, 43 | payload, 44 | domainname: tenant.domainName, 45 | }); 46 | 47 | expect(response.body).toMatchSnapshot(); 48 | expect(response.body.message).toBe(MESSAGE.LOGIN.INVALID_LOGIN); 49 | expect(response.status).toBe(401); 50 | }); 51 | 52 | it('should return error if user dont exist', async () => { 53 | const tenant = await createTenant(); 54 | const payload = { 55 | email: 'userdontexist@test.com', 56 | password: '123456', 57 | tenantId: tenant._id, 58 | }; 59 | 60 | const response = await createApiCall({ 61 | url, 62 | payload, 63 | domainname: tenant.domainName, 64 | }); 65 | 66 | expect(response.body).toMatchSnapshot(); 67 | expect(response.body.message).toBe(MESSAGE.LOGIN.INCORRECT); 68 | expect(response.status).toBe(401); 69 | }); 70 | 71 | it('should return error if user password is wrong', async () => { 72 | const tenant = await createTenant(); 73 | const user = await createUser({ email: 'test@test.com', password: '123456' }); 74 | 75 | const payload = { 76 | email: user.email, 77 | password: '654321', 78 | tenantId: tenant._id, 79 | }; 80 | 81 | const response = await createApiCall({ 82 | url, 83 | payload, 84 | domainname: tenant.domainName, 85 | }); 86 | 87 | expect(response.body).toMatchSnapshot(); 88 | expect(response.body.message).toBe(MESSAGE.LOGIN.INCORRECT); 89 | expect(response.status).toBe(401); 90 | }); 91 | 92 | it('should made login with infos correct', async () => { 93 | const tenant = await createTenant(); 94 | const user = await createUser({ email: 'test@test.com', password: '123456' }); 95 | 96 | const payload = { 97 | email: user.email, 98 | password: '123456', 99 | tenantId: tenant._id, 100 | }; 101 | 102 | const response = await createApiCall({ 103 | url, 104 | payload, 105 | domainname: tenant.domainName, 106 | }); 107 | 108 | expect(response.body).toMatchSnapshot(); 109 | expect(response.body.message).toBe(MESSAGE.LOGIN.SUCCESS); 110 | expect(response.status).toBe(200); 111 | }); 112 | -------------------------------------------------------------------------------- /packages/api/src/api/auth/v1/login/authEmail.ts: -------------------------------------------------------------------------------- 1 | import { UserModel } from '@fp/modules'; 2 | 3 | import { ERROR } from '../../../../common/consts'; 4 | import { MESSAGE } from '../../../ApiHelpers'; 5 | 6 | const authEmail = async (ctx) => { 7 | const { tenant } = ctx; 8 | const { email } = ctx.request.body; 9 | 10 | const user = await UserModel.findOne({ 11 | email, 12 | tenant, 13 | }); 14 | 15 | if (!user) { 16 | ctx.status = 400; 17 | ctx.body = { 18 | status: ERROR, 19 | message: MESSAGE.LOGIN.INCORRECT, 20 | }; 21 | return; 22 | } 23 | 24 | ctx.status = 200; 25 | ctx.body = { 26 | companyId: user.tenant._id, 27 | message: MESSAGE.LOGIN.EMAIL_SUCCESS, 28 | }; 29 | return; 30 | }; 31 | 32 | export default authEmail; 33 | -------------------------------------------------------------------------------- /packages/api/src/api/auth/v1/login/authEmail.yml: -------------------------------------------------------------------------------- 1 | /api/auth/v1/login/email: 2 | post: 3 | tags: 4 | - auth 5 | summary: Login by Email 6 | description: Endpoint to login with email and validate it 7 | requestBody: 8 | description: Data to do login by email 9 | required: true 10 | content: 11 | application/json: 12 | schema: 13 | type: object 14 | email: 15 | type: string 16 | example: 17 | email: "test@test.com" 18 | responses: 19 | "200": 20 | description: Email valid 21 | content: 22 | application/json: 23 | schema: 24 | type: object 25 | properties: 26 | companyId: 27 | type: string 28 | message: 29 | type: string 30 | example: 31 | companyId: "9134e286-6f71-427a-bf00-241681624586" 32 | message: "Email successful" 33 | "400": 34 | description: An error message 35 | content: 36 | application/json: 37 | schema: 38 | type: object 39 | properties: 40 | status: 41 | type: string 42 | message: 43 | type: string 44 | -------------------------------------------------------------------------------- /packages/api/src/api/auth/v1/login/authPassword.ts: -------------------------------------------------------------------------------- 1 | import { UserModel } from '@fp/modules'; 2 | 3 | import { ERROR } from '../../../../common/consts'; 4 | import { generateToken } from '../../../../auth/auth'; 5 | import { MESSAGE } from '../../../ApiHelpers'; 6 | 7 | const authPassword = async ctx => { 8 | const { tenant } = ctx; 9 | const { email, password, tenantId } = ctx.request.body; 10 | 11 | if (tenant._id.toString().trim() !== tenantId.toString().trim()) { 12 | ctx.status = 401; 13 | ctx.body = { 14 | status: ERROR, 15 | message: MESSAGE.LOGIN.INVALID_TENANT, 16 | user: null, 17 | }; 18 | return; 19 | } 20 | 21 | if (!email || !password || !tenantId) { 22 | ctx.status = 401; 23 | ctx.body = { 24 | status: ERROR, 25 | message: MESSAGE.LOGIN.INVALID_LOGIN, 26 | user: null, 27 | }; 28 | return; 29 | } 30 | 31 | const user = await UserModel.findOne({ 32 | email, 33 | tenant, 34 | }); 35 | 36 | if (!user) { 37 | ctx.status = 401; 38 | ctx.body = { 39 | status: ERROR, 40 | message: MESSAGE.LOGIN.INCORRECT, 41 | user: null, 42 | }; 43 | return; 44 | } 45 | 46 | let correctPassword = null; 47 | try { 48 | correctPassword = user.authenticate(password); 49 | } catch (err) { 50 | ctx.status = 401; 51 | ctx.body = { 52 | status: ERROR, 53 | message: MESSAGE.LOGIN.INCORRECT, 54 | user: null, 55 | }; 56 | return; 57 | } 58 | 59 | if (!correctPassword) { 60 | ctx.status = 401; 61 | ctx.body = { 62 | status: ERROR, 63 | message: MESSAGE.LOGIN.INCORRECT, 64 | user: null, 65 | }; 66 | return; 67 | } 68 | 69 | ctx.status = 200; 70 | ctx.body = { 71 | message: MESSAGE.LOGIN.SUCCESS, 72 | token: generateToken(tenant, user), 73 | }; 74 | return; 75 | }; 76 | 77 | export default authPassword; 78 | -------------------------------------------------------------------------------- /packages/api/src/api/user/v1/__tests__/userDelete.spec.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | 3 | import { createTenant, createUser } from '@fp/modules'; 4 | 5 | import { clearDbAndRestartCounters, connectMongoose, disconnectMongoose } from '@fp/test'; 6 | 7 | import { base64 } from '../../../../auth/base64'; 8 | import { MESSAGE } from '../../../ApiHelpers'; 9 | import { createDeleteApiCall } from '../../../../../test'; 10 | 11 | beforeAll(connectMongoose); 12 | 13 | beforeEach(async () => { 14 | await clearDbAndRestartCounters(); 15 | await jest.clearAllMocks(); 16 | }); 17 | 18 | afterAll(disconnectMongoose); 19 | 20 | const url = '/api/user/v1/users'; 21 | 22 | it('should return id from user deleted', async () => { 23 | const tenant = await createTenant(); 24 | const user = await createUser({ tenant }); 25 | 26 | const authorization = base64(`${tenant._id}:${user._id}`); 27 | 28 | const userToDelete = await createUser({ tenant }); 29 | 30 | const response = await createDeleteApiCall({ 31 | url: `${url}/${userToDelete._id}`, 32 | authorization, 33 | domainname: tenant.domainName, 34 | }); 35 | 36 | // expect(sanitizeTestObject(response.body)).toMatchSnapshot(); 37 | expect(response.status).toBe(200); 38 | expect(response.body.user.toString()).toBe(userToDelete._id.toString()); 39 | }); 40 | 41 | it('should return error for id fake', async () => { 42 | const tenant = await createTenant(); 43 | const user = await createUser({ tenant }); 44 | const authorization = base64(`${tenant._id}:${user._id}`); 45 | 46 | const userToDelete = { 47 | _id: 'fake_id', 48 | }; 49 | 50 | const response = await createDeleteApiCall({ 51 | url: `${url}/${userToDelete._id}`, 52 | authorization, 53 | domainname: tenant.domainName, 54 | }); 55 | 56 | // expect(sanitizeTestObject(response.body)).toMatchSnapshot(); 57 | expect(response.status).toBe(400); 58 | expect(response.body.error.message).toBe(MESSAGE.COMMON.INVALID_ID); 59 | }); 60 | 61 | it('should return error for id inexistent', async () => { 62 | const tenant = await createTenant(); 63 | const user = await createUser({ tenant }); 64 | const authorization = base64(`${tenant._id}:${user._id}`); 65 | 66 | const userToDelete = { 67 | _id: new Types.ObjectId(), 68 | }; 69 | 70 | const response = await createDeleteApiCall({ 71 | url: `${url}/${userToDelete._id}`, 72 | authorization, 73 | domainname: tenant.domainName, 74 | }); 75 | 76 | // expect(sanitizeTestObject(response.body)).toMatchSnapshot(); 77 | expect(response.status).toBe(400); 78 | expect(response.body.error.message).toBe(MESSAGE.USER.NOT_FOUND); 79 | }); 80 | 81 | it('should return error as user not found when try delete an user already deleted', async () => { 82 | const tenant = await createTenant(); 83 | const user = await createUser({ tenant }); 84 | const authorization = base64(`${tenant._id}:${user._id}`); 85 | 86 | const userToDelete = await createUser({ tenant, removedAt: new Date() }); 87 | 88 | const response = await createDeleteApiCall({ 89 | url: `${url}/${userToDelete._id}`, 90 | authorization, 91 | domainname: tenant.domainName, 92 | }); 93 | 94 | // expect(sanitizeTestObject(response.body)).toMatchSnapshot(); 95 | expect(response.status).toBe(400); 96 | expect(response.body.error.message).toBe(MESSAGE.USER.NOT_FOUND); 97 | }); 98 | -------------------------------------------------------------------------------- /packages/api/src/api/user/v1/__tests__/userGet.spec.ts: -------------------------------------------------------------------------------- 1 | import { clearDbAndRestartCounters, connectMongoose, disconnectMongoose } from '@fp/test'; 2 | import { createUser, createTenant } from '@fp/modules'; 3 | 4 | import { base64 } from '../../../../auth/base64'; 5 | import { createGetApiCall } from '../../../../../test'; 6 | 7 | beforeAll(connectMongoose); 8 | 9 | beforeEach(async () => { 10 | await clearDbAndRestartCounters(); 11 | await jest.clearAllMocks(); 12 | }); 13 | 14 | afterAll(disconnectMongoose); 15 | 16 | const getUrl = (id: string) => `/api/user/v1/users/${id}`; 17 | 18 | it('should return 400 if id is invalid', async () => { 19 | const tenant = await createTenant(); 20 | const user = await createUser({ tenant }); 21 | 22 | const authorization = base64(`${tenant._id}:${user._id}`); 23 | 24 | const url = getUrl('1'); 25 | 26 | const response = await createGetApiCall({ 27 | url, 28 | authorization, 29 | domainname: tenant.domainName, 30 | }); 31 | 32 | expect(response.status).toBe(400); 33 | expect(response.body.user).toBe(null); 34 | expect(response.body.message).toBe('User not found'); 35 | 36 | // eslint-disable-next-line 37 | // expect(response.body).toMatchSnapshot(); 38 | }); 39 | 40 | it('should return user by object id', async () => { 41 | const tenant = await createTenant(); 42 | const user = await createUser({ tenant }); 43 | 44 | const authorization = base64(`${tenant._id}:${user._id}`); 45 | 46 | const url = getUrl(user._id.toString()); 47 | 48 | const response = await createGetApiCall({ 49 | url, 50 | authorization, 51 | domainname: tenant.domainName, 52 | }); 53 | 54 | expect(response.status).toBe(200); 55 | expect(response.body.user).not.toBe(null); 56 | 57 | // eslint-disable-next-line 58 | // expect(sanitizeTestObject(response.body)).toMatchSnapshot(); 59 | }); 60 | -------------------------------------------------------------------------------- /packages/api/src/api/user/v1/__tests__/userGetAll.spec.tsx: -------------------------------------------------------------------------------- 1 | import { clearDbAndRestartCounters, connectMongoose, disconnectMongoose } from '@fp/test'; 2 | import { createUser, createTenant } from '@fp/modules'; 3 | 4 | import { base64 } from '../../../../auth/base64'; 5 | import { createGetApiCall } from '../../../../../test'; 6 | import { MESSAGE } from '../../../ApiHelpers'; 7 | 8 | beforeAll(connectMongoose); 9 | 10 | beforeEach(clearDbAndRestartCounters); 11 | 12 | afterAll(disconnectMongoose); 13 | 14 | const url = '/api/user/v1/users'; 15 | 16 | it('should return a list of users', async () => { 17 | const tenant = await createTenant(); 18 | const user = await createUser({ tenant }); 19 | 20 | const authorization = base64(`${tenant._id}:${user._id}`); 21 | 22 | const response = await createGetApiCall({ 23 | url, 24 | authorization, 25 | domainname: tenant.domainName, 26 | }); 27 | 28 | expect(response.status).toBe(200); 29 | expect(response.body.users.length).toBe(1); 30 | }); 31 | 32 | it('should return 100 users if no skip limit is not specific', async () => { 33 | const tenant = await createTenant(); 34 | const user = await createUser({ tenant, name: 'user#1' }); 35 | 36 | const authorization = base64(`${tenant._id}:${user._id}`); 37 | 38 | for (const i of Array.from(Array(110).keys())) { 39 | await createUser({ tenant, name: `user#${i + 2}` }); 40 | } 41 | 42 | const response = await createGetApiCall({ 43 | url, 44 | authorization, 45 | domainname: tenant.domainName, 46 | }); 47 | 48 | expect(response.status).toBe(200); 49 | expect(response.body.users.length).toBe(100); 50 | }); 51 | 52 | it('should paginate with skip and limit 100 users if no skip limit is not specific', async () => { 53 | const tenant = await createTenant(); 54 | const user = await createUser({ tenant, name: 'user#1' }); 55 | const authorization = base64(`${tenant._id}:${user._id}`); 56 | 57 | for (const i of Array.from(Array(109).keys())) { 58 | await createUser({ tenant, name: `user#${i + 2}` }); 59 | } 60 | 61 | const response = await createGetApiCall({ 62 | url: `${url}?skip=90&limit=10`, 63 | authorization, 64 | domainname: tenant.domainName, 65 | }); 66 | 67 | expect(response.status).toBe(200); 68 | expect(response.body.users.length).toBe(10); 69 | 70 | const u = response.body.users[0]; 71 | 72 | expect(u.name).toBe('user#91'); 73 | }); 74 | 75 | it('should paginate with skip and limit 10 users, and do again the call to api and paginate to 20', async () => { 76 | const tenant = await createTenant(); 77 | const user = await createUser({ tenant, name: 'user#1' }); 78 | const authorization = base64(`${tenant._id}:${user._id}`); 79 | 80 | for (const i of Array.from(Array(15).keys())) { 81 | await createUser({ tenant, name: `user#${i + 2}` }); 82 | } 83 | 84 | const response = await createGetApiCall({ 85 | url: `${url}?skip=0&limit=10`, 86 | authorization, 87 | domainname: tenant.domainName, 88 | }); 89 | 90 | expect(response.status).toBe(200); 91 | expect(response.body.users.length).toBe(10); 92 | 93 | const u = response.body.users[0]; 94 | 95 | expect(u.name).toBe('user#1'); 96 | 97 | const responseB = await createGetApiCall({ 98 | url: `${url}?skip=10&limit=10`, 99 | authorization, 100 | domainname: tenant.domainName, 101 | }); 102 | 103 | const userB = responseB.body.users[0]; 104 | 105 | expect(userB.name).toBe('user#11'); 106 | expect(responseB.body.pageInfo.hasNextPage).toBeFalsy(); 107 | }); 108 | 109 | it('should not accept skip negative', async () => { 110 | const tenant = await createTenant(); 111 | const user = await createUser({ tenant, name: 'user#1' }); 112 | const authorization = base64(`${tenant._id}:${user._id}`); 113 | 114 | for (const i of Array.from(Array(5).keys())) { 115 | await createUser({ tenant, name: `user#${i + 2}` }); 116 | } 117 | 118 | const response = await createGetApiCall({ 119 | url: `${url}?skip=${-10}&limit=10`, 120 | authorization, 121 | domainname: tenant.domainName, 122 | }); 123 | 124 | expect(response.status).toBe(422); 125 | expect(response.body.errors.length).toBe(1); 126 | expect(response.body.errors[0].message).toBe(MESSAGE.PAGE_INFO.ERRORS.NEGATIVE); 127 | // expect(sanitizeTestObject(response.body)).toMatchSnapshot(); 128 | }); 129 | 130 | it('should not accept limit negative', async () => { 131 | const tenant = await createTenant(); 132 | const user = await createUser({ tenant, name: 'user#1' }); 133 | const authorization = base64(`${tenant._id}:${user._id}`); 134 | 135 | for (const i of Array.from(Array(5).keys())) { 136 | await createUser({ tenant, name: `user#${i + 2}` }); 137 | } 138 | 139 | const response = await createGetApiCall({ 140 | url: `${url}?skip=10&limit=${-10}`, 141 | authorization, 142 | domainname: tenant.domainName, 143 | }); 144 | 145 | expect(response.status).toBe(422); 146 | expect(response.body.errors.length).toBe(1); 147 | expect(response.body.errors[0].message).toBe(MESSAGE.PAGE_INFO.ERRORS.NEGATIVE); 148 | // expect(sanitizeTestObject(response.body)).toMatchSnapshot(); 149 | }); 150 | 151 | it('should not accept skip and limit negative', async () => { 152 | const tenant = await createTenant(); 153 | const user = await createUser({ tenant, name: 'user#1' }); 154 | const authorization = base64(`${tenant._id}:${user._id}`); 155 | 156 | for (const i of Array.from(Array(5).keys())) { 157 | await createUser({ tenant, name: `user#${i + 2}` }); 158 | } 159 | 160 | const response = await createGetApiCall({ 161 | url: `${url}?skip=${-10}&limit=${-10}`, 162 | authorization, 163 | domainname: tenant.domainName, 164 | }); 165 | 166 | expect(response.status).toBe(422); 167 | expect(response.body.errors.length).toBe(1); 168 | expect(response.body.errors[0].message).toBe(MESSAGE.PAGE_INFO.ERRORS.NEGATIVE); 169 | // expect(sanitizeTestObject(response.body)).toMatchSnapshot(); 170 | }); 171 | 172 | it('should not return user from another tenant', async () => { 173 | const anotherTenant = await createTenant(); 174 | await createUser({ tenant: anotherTenant }); 175 | 176 | const tenant = await createTenant(); 177 | const user = await createUser({ tenant }); 178 | 179 | const authorization = base64(`${tenant._id}:${user._id}`); 180 | 181 | const response = await createGetApiCall({ 182 | url, 183 | authorization, 184 | domainname: tenant.domainName, 185 | }); 186 | 187 | expect(response.status).toBe(200); 188 | expect(response.body.users.length).toBe(1); 189 | 190 | // eslint-disable-next-line 191 | // expect(response.body).toMatchSnapshot(); 192 | }); 193 | -------------------------------------------------------------------------------- /packages/api/src/api/user/v1/__tests__/userPost.spec.ts: -------------------------------------------------------------------------------- 1 | import { createTenant, createUser } from '@fp/modules'; 2 | 3 | import { clearDbAndRestartCounters, connectMongoose, disconnectMongoose } from '@fp/test'; 4 | 5 | import { base64 } from '../../../../auth/base64'; 6 | import { createApiCall } from '../../../../../test'; 7 | 8 | import { MESSAGE } from '../../../ApiHelpers'; 9 | 10 | beforeAll(connectMongoose); 11 | 12 | beforeEach(clearDbAndRestartCounters); 13 | 14 | afterAll(disconnectMongoose); 15 | 16 | const url = '/api/user/v1/users'; 17 | 18 | it('should return error if user was not passed', async () => { 19 | const tenant = await createTenant(); 20 | const user = await createUser({ tenant }); 21 | const authorization = base64(`${tenant._id}:${user._id}`); 22 | 23 | const payload = {}; 24 | 25 | const response = await createApiCall({ 26 | url, 27 | authorization, 28 | payload, 29 | domainname: tenant.domainName, 30 | }); 31 | 32 | expect(response.status).toBe(400); 33 | expect(response.body.message).toBe(MESSAGE.USER.MISSING); 34 | }); 35 | 36 | it('should return error if email was not passed', async () => { 37 | const tenant = await createTenant(); 38 | const user = await createUser({ tenant }); 39 | const authorization = base64(`${tenant._id}:${user._id}`); 40 | 41 | const payload = { 42 | user: { 43 | name: 'User A', 44 | }, 45 | }; 46 | 47 | const response = await createApiCall({ 48 | url, 49 | authorization, 50 | payload, 51 | domainname: tenant.domainName, 52 | }); 53 | 54 | expect(response.status).toBe(400); 55 | expect(response.body.message).toBe(MESSAGE.COMMON.EMAIL); 56 | }); 57 | 58 | it('should return error if email is not valid', async () => { 59 | const tenant = await createTenant(); 60 | const user = await createUser({ tenant }); 61 | const authorization = base64(`${tenant._id}:${user._id}`); 62 | 63 | const payload = { 64 | user: { 65 | name: 'User A', 66 | email: 'abvc', 67 | }, 68 | }; 69 | 70 | const response = await createApiCall({ 71 | url, 72 | authorization, 73 | payload, 74 | domainname: tenant.domainName, 75 | }); 76 | 77 | expect(response.status).toBe(400); 78 | expect(response.body.message).toBe(MESSAGE.COMMON.EMAIL_INVALID); 79 | }); 80 | 81 | it('should return error if id passed is not object idvalid', async () => { 82 | const tenant = await createTenant(); 83 | const user = await createUser({ tenant }); 84 | const authorization = base64(`${tenant._id}:${user._id}`); 85 | 86 | const payload = { 87 | user: { 88 | id: '123', 89 | name: 'User A', 90 | email: 'test@test.com', 91 | }, 92 | }; 93 | 94 | const response = await createApiCall({ 95 | url, 96 | authorization, 97 | payload, 98 | domainname: tenant.domainName, 99 | }); 100 | 101 | expect(response.status).toBe(400); 102 | expect(response.body.message).toBe(MESSAGE.COMMON.INVALID_ID); 103 | }); 104 | 105 | it('should return error if id passed not found', async () => { 106 | const tenant = await createTenant(); 107 | const user = await createUser({ tenant }); 108 | const authorization = base64(`${tenant._id}:${user._id}`); 109 | 110 | const payload = { 111 | user: { 112 | id: '5ef4f0c34ed6710503da88b6', 113 | name: 'User A', 114 | email: 'test@test.com', 115 | }, 116 | }; 117 | 118 | const response = await createApiCall({ 119 | url, 120 | authorization, 121 | payload, 122 | domainname: tenant.domainName, 123 | }); 124 | 125 | expect(response.status).toBe(400); 126 | expect(response.body.message).toBe(MESSAGE.USER.NOT_FOUND); 127 | }); 128 | 129 | it('should return error for new user without pw', async () => { 130 | const tenant = await createTenant(); 131 | const user = await createUser({ tenant }); 132 | const authorization = base64(`${tenant._id}:${user._id}`); 133 | 134 | const payload = { 135 | user: { 136 | name: 'Awesome Name', 137 | email: `email@domain.tld`, 138 | }, 139 | }; 140 | 141 | const response = await createApiCall({ 142 | url, 143 | authorization, 144 | payload, 145 | domainname: tenant.domainName, 146 | }); 147 | 148 | expect(response.status).toBe(400); 149 | expect(response.body.message).toBe(MESSAGE.USER.PASSWORD); 150 | }); 151 | 152 | it('should return OK for new user', async () => { 153 | const tenant = await createTenant(); 154 | const user = await createUser({ tenant }); 155 | const authorization = base64(`${tenant._id}:${user._id}`); 156 | 157 | const payload = { 158 | user: { 159 | name: 'Awesome Name', 160 | email: `email@domain.tld`, 161 | password: '123456', 162 | }, 163 | }; 164 | 165 | const response = await createApiCall({ 166 | url, 167 | authorization, 168 | payload, 169 | domainname: tenant.domainName, 170 | }); 171 | 172 | expect(response.status).toBe(200); 173 | expect(response.body.message).toBe('User successfully created'); 174 | }); 175 | 176 | it('should return ok for user updated', async () => { 177 | const tenant = await createTenant(); 178 | const user = await createUser({ tenant }); 179 | const userToUpdated = await createUser({ tenant }); 180 | const authorization = base64(`${tenant._id}:${user._id}`); 181 | 182 | const payload = { 183 | user: { 184 | id: userToUpdated._id, 185 | name: 'Awesome Name Updated', 186 | email: userToUpdated.email, 187 | }, 188 | }; 189 | 190 | const response = await createApiCall({ 191 | url, 192 | authorization, 193 | payload, 194 | domainname: tenant.domainName, 195 | }); 196 | 197 | expect(response.body.message).toBe('User successfully updated'); 198 | expect(response.status).toBe(200); 199 | }); 200 | -------------------------------------------------------------------------------- /packages/api/src/api/user/v1/userDelete.ts: -------------------------------------------------------------------------------- 1 | import { UserModel } from '@fp/modules'; 2 | 3 | import { AuthContext } from '../../../auth/auth'; 4 | import { checkObjectId, MESSAGE } from '../../ApiHelpers'; 5 | import { ERROR, OK } from '../../../common/consts'; 6 | 7 | const validate = async (ctx: AuthContext, id: string) => { 8 | const { tenant } = ctx; 9 | 10 | const { error } = checkObjectId(id); 11 | 12 | if (error) { 13 | return { 14 | errorValidateUser: { data: id, message: MESSAGE.COMMON.INVALID_ID }, 15 | validateUser: null, 16 | }; 17 | } 18 | 19 | const userAlreadyDeleted = await UserModel.findOne({ 20 | _id: id, 21 | tenant, 22 | removedAt: { 23 | $ne: null, 24 | }, 25 | }); 26 | 27 | if (userAlreadyDeleted) { 28 | return { 29 | errorValidateUser: { data: id, message: MESSAGE.USER.NOT_FOUND }, 30 | validateUser: null, 31 | }; 32 | } 33 | 34 | const user = await UserModel.findOne({ 35 | _id: id, 36 | tenant, 37 | removedAt: null, 38 | }); 39 | 40 | if (!user) { 41 | return { 42 | errorValidateUser: { data: id, message: MESSAGE.USER.NOT_FOUND }, 43 | validateUser: null, 44 | }; 45 | } 46 | 47 | return { 48 | errorValidateUser: null, 49 | validateUser: user, 50 | }; 51 | }; 52 | 53 | const deleteUser = async (ctx: AuthContext, id: string) => { 54 | const { tenant } = ctx; 55 | const { errorValidateUser, validateUser } = await validate(ctx, id); 56 | 57 | if (errorValidateUser) { 58 | return { 59 | error: errorValidateUser, 60 | user: null, 61 | }; 62 | } 63 | 64 | await UserModel.updateOne( 65 | { 66 | _id: validateUser, 67 | tenant, 68 | }, 69 | { 70 | $set: { 71 | removedAt: new Date(), 72 | }, 73 | }, 74 | ); 75 | 76 | ctx.updated++; 77 | 78 | return { 79 | error: null, 80 | user: validateUser._id, 81 | }; 82 | }; 83 | 84 | const userDelete = async (ctx: AuthContext) => { 85 | const { id } = ctx.params; 86 | 87 | if (!id) { 88 | ctx.status = 400; 89 | ctx.body = { 90 | status: ERROR, 91 | error: MESSAGE.COMMON.INVALID_ID, 92 | user: null, 93 | }; 94 | return; 95 | } 96 | 97 | try { 98 | const { error, user } = await deleteUser(ctx, id); 99 | 100 | if (error) { 101 | ctx.status = 400; 102 | ctx.body = { 103 | status: ERROR, 104 | error, 105 | user: null, 106 | }; 107 | 108 | return; 109 | } 110 | 111 | ctx.status = 200; 112 | ctx.body = { 113 | status: OK, 114 | user, 115 | }; 116 | 117 | return; 118 | } catch (err) { 119 | // eslint-disable-next-line 120 | console.log('err:', err); 121 | 122 | ctx.status = 500; 123 | ctx.body = { 124 | status: ERROR, 125 | error: err, 126 | }; 127 | } 128 | }; 129 | 130 | export default userDelete; 131 | -------------------------------------------------------------------------------- /packages/api/src/api/user/v1/userGet.ts: -------------------------------------------------------------------------------- 1 | import { UserModel } from '@fp/modules'; 2 | 3 | import { AuthContext } from '../../../auth/auth'; 4 | import { ERROR, OK } from '../../../common/consts'; 5 | import { checkObjectId, MESSAGE } from '../../ApiHelpers'; 6 | 7 | export const userSelection = { 8 | _id: 1, 9 | name: 1, 10 | }; 11 | 12 | export const getUserApi = async (conditions: object) => { 13 | const user = await UserModel.findOne(conditions) 14 | .select(userSelection) 15 | .lean(); 16 | 17 | return user; 18 | }; 19 | 20 | const userGet = async (ctx: AuthContext) => { 21 | const { tenant } = ctx; 22 | const { id } = ctx.params; 23 | 24 | try { 25 | const { error, ...validatedId } = checkObjectId(id); 26 | 27 | if (error) { 28 | ctx.status = 400; 29 | ctx.body = { 30 | status: ERROR, 31 | message: MESSAGE.USER.NOT_FOUND, 32 | user: null, 33 | }; 34 | return; 35 | } 36 | 37 | const conditions = { 38 | ...validatedId, 39 | tenant, 40 | removedAt: null, 41 | }; 42 | 43 | const user = await getUserApi(conditions); 44 | 45 | if (!user) { 46 | ctx.status = 400; 47 | ctx.body = { 48 | status: ERROR, 49 | message: MESSAGE.USER.NOT_FOUND, 50 | user: null, 51 | }; 52 | return; 53 | } 54 | 55 | ctx.status = 200; 56 | ctx.body = { 57 | status: OK, 58 | user: user, 59 | }; 60 | 61 | return; 62 | } catch (err) { 63 | // eslint-disable-next-line 64 | console.log('err:', err); 65 | 66 | ctx.status = 500; 67 | ctx.body = { 68 | status: ERROR, 69 | message: err, 70 | }; 71 | } 72 | }; 73 | 74 | export default userGet; 75 | -------------------------------------------------------------------------------- /packages/api/src/api/user/v1/userGetAll.ts: -------------------------------------------------------------------------------- 1 | import { UserModel } from '@fp/modules'; 2 | 3 | import { getPageInfo, getSkipAndLimit } from '../../ApiHelpers'; 4 | import { AuthContext } from '../../../auth/auth'; 5 | import { ERROR, OK } from '../../../common/consts'; 6 | 7 | import { userSelection } from './userGet'; 8 | 9 | const userGetAll = async (ctx: AuthContext) => { 10 | const { tenant } = ctx; 11 | 12 | const { skip, limit } = getSkipAndLimit(ctx); 13 | 14 | const conditions = { 15 | tenant, 16 | removedAt: null, 17 | }; 18 | 19 | try { 20 | const users = await UserModel.find(conditions) 21 | .select(userSelection) 22 | .skip(skip) 23 | .limit(limit) 24 | .lean(); 25 | 26 | // check why we use map user api, I guess that is to output 27 | // const mappedUsers = users.map((user: IUser) => mapUserApi(user)); 28 | const pageInfo = await getPageInfo(ctx, UserModel); 29 | 30 | if (pageInfo.errors) { 31 | ctx.status = 422; 32 | ctx.body = { 33 | status: ERROR, 34 | errors: pageInfo.errors, 35 | }; 36 | 37 | return; 38 | } 39 | 40 | ctx.status = 200; 41 | ctx.body = { 42 | pageInfo, 43 | status: OK, 44 | users: users, 45 | }; 46 | 47 | return; 48 | } catch (err) { 49 | // eslint-disable-next-line 50 | console.log('err:', err); 51 | 52 | ctx.status = 500; 53 | ctx.body = { 54 | status: ERROR, 55 | message: err, 56 | }; 57 | } 58 | }; 59 | 60 | export default userGetAll; 61 | -------------------------------------------------------------------------------- /packages/api/src/api/user/v1/userPost.ts: -------------------------------------------------------------------------------- 1 | import { AuthContext } from '../../../auth/auth'; 2 | import { ERROR, OK } from '../../../common/consts'; 3 | 4 | import { MESSAGE } from '../../ApiHelpers'; 5 | 6 | import { userUpdateOrCreate } from './userUpdateOrCreate'; 7 | 8 | export const userPost = async (ctx: AuthContext) => { 9 | const { user = null } = ctx.request.body; 10 | 11 | if (!user) { 12 | // eslint-disable-next-line 13 | console.log(MESSAGE.USER.MISSING); 14 | 15 | ctx.status = 400; 16 | ctx.body = { 17 | status: ERROR, 18 | message: MESSAGE.USER.MISSING, 19 | }; 20 | 21 | return; 22 | } 23 | 24 | try { 25 | ctx.body = { 26 | errors: [], 27 | data: {}, 28 | }; 29 | 30 | const { error, message: messageApi, user: userApi } = await userUpdateOrCreate(ctx, user); 31 | 32 | if (error) { 33 | ctx.status = 400; 34 | ctx.body = { 35 | status: ERROR, 36 | message: error, 37 | user: user, 38 | }; 39 | return; 40 | } 41 | 42 | ctx.status = 200; 43 | ctx.body = { 44 | status: OK, 45 | message: messageApi, 46 | user: userApi, 47 | }; 48 | 49 | return; 50 | } catch (err) { 51 | // eslint-disable-next-line 52 | console.log('err:', err); 53 | 54 | ctx.status = 500; 55 | ctx.body = { 56 | status: ERROR, 57 | message: err, 58 | }; 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /packages/api/src/api/user/v1/userUpdateOrCreate.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa'; 2 | 3 | import { getUserApi } from './userGet'; 4 | import { ApiUser, getExistingUser, handleCreateNewUser, handleUpdateUser, validateUserDataFromApi } from './userUtils'; 5 | 6 | export const userUpdateOrCreate = async (ctx: Context, apiUser: ApiUser) => { 7 | const { error, user } = await validateUserDataFromApi(apiUser); 8 | 9 | if (error != null) { 10 | return { 11 | error, 12 | }; 13 | } 14 | 15 | const hasExistingUser = await getExistingUser(ctx, user); 16 | 17 | if (hasExistingUser.error != null) { 18 | return { 19 | error: hasExistingUser.error, 20 | }; 21 | } 22 | 23 | if (hasExistingUser.user) { 24 | const existingUser = hasExistingUser.user; 25 | const { userUpdatedByApi, errorUserUpdatedByApi } = await handleUpdateUser(ctx, user, existingUser); 26 | 27 | if (errorUserUpdatedByApi) { 28 | return { 29 | error: errorUserUpdatedByApi, 30 | }; 31 | } 32 | 33 | ctx.updated++; 34 | 35 | const updatedUser = await getUserApi(userUpdatedByApi._id); 36 | 37 | return { 38 | error: errorUserUpdatedByApi, 39 | message: 'User successfully updated', 40 | user: updatedUser, 41 | }; 42 | } 43 | 44 | const { newUser, errorNewUser } = await handleCreateNewUser(ctx, user); 45 | 46 | if (errorNewUser) { 47 | return { 48 | error: errorNewUser, 49 | }; 50 | } 51 | 52 | const newUserApi = await getUserApi(newUser._id); 53 | 54 | return { 55 | error: null, 56 | message: 'User successfully created', 57 | user: newUserApi, 58 | }; 59 | }; 60 | -------------------------------------------------------------------------------- /packages/api/src/api/user/v1/userUtils.ts: -------------------------------------------------------------------------------- 1 | import { IUser, UserModel } from '@fp/modules'; 2 | import { DeepPartial } from '@fp/types'; 3 | 4 | import * as yup from 'yup'; 5 | 6 | import { Types } from 'mongoose'; 7 | 8 | import { getObjectId } from '@fp/test'; 9 | 10 | import { MESSAGE } from '../../ApiHelpers'; 11 | 12 | export type ApiUser = { 13 | id?: string; 14 | name: string; 15 | email: string; 16 | password?: string; 17 | }; 18 | 19 | type ErrorObj = { 20 | data: object; 21 | message: string; 22 | }; 23 | 24 | type ValidateUserResult = { 25 | error: string[] | null | ErrorObj[]; 26 | user: DeepPartial | null; 27 | }; 28 | 29 | yup.addMethod(yup.string, 'objectId', function() { 30 | return this.test('id', MESSAGE.COMMON.INVALID_ID, value => { 31 | if (!value) { 32 | return true; 33 | } 34 | 35 | const id = getObjectId(value); 36 | 37 | if (id instanceof Types.ObjectId) { 38 | return true; 39 | } 40 | 41 | return false; 42 | }); 43 | }); 44 | 45 | const userSchema = yup.object().shape({ 46 | id: yup 47 | .string() 48 | .objectId() 49 | .nullable(), 50 | name: yup.string().required(), 51 | email: yup 52 | .string() 53 | .email() 54 | .required(), 55 | password: yup.string().nullable(), 56 | }); 57 | 58 | export const validateUserDataFromApi = async (apiUser: ApiUser): Promise => { 59 | try { 60 | await userSchema.validate(apiUser); 61 | } catch (error) { 62 | if (error instanceof yup.ValidationError) { 63 | return { 64 | error: error.message, 65 | user: null, 66 | }; 67 | } 68 | 69 | // fallback 70 | return { 71 | error: MESSAGE.COMMON.YUP_VALIDATION, 72 | user: null, 73 | }; 74 | } 75 | 76 | return { 77 | error: null, 78 | user: apiUser, 79 | }; 80 | }; 81 | 82 | export const getExistingUser = async (ctx: Context, user: ApiUser) => { 83 | if (!user.email) { 84 | return { 85 | error: MESSAGE.USER.PRIMARY_KEY, 86 | user: null, 87 | }; 88 | } 89 | 90 | const findUser = await UserModel.findOne({ 91 | _id: user.id, 92 | email: user.email, 93 | }); 94 | 95 | if (user.id && !findUser) { 96 | return { 97 | error: MESSAGE.USER.NOT_FOUND, 98 | user: null, 99 | }; 100 | } 101 | 102 | return { 103 | error: null, 104 | user: findUser, 105 | }; 106 | }; 107 | 108 | export const handleUpdateUser = async (ctx: Context, user: ApiUser, existingUser: IUser) => { 109 | const userUpdatedByApi = await UserModel.findOneAndUpdate( 110 | { 111 | _id: existingUser._id, 112 | }, 113 | { 114 | $set: { 115 | name: user.name, 116 | email: user.email, 117 | password: user.password, 118 | }, 119 | }, 120 | ); 121 | 122 | if (!userUpdatedByApi) { 123 | return { 124 | errorUserUpdatedByApi: MESSAGE.USER.UPDATING, 125 | userUpdatedByApi: null, 126 | }; 127 | } 128 | 129 | return { errorUserUpdatedByApi: null, userUpdatedByApi }; 130 | }; 131 | 132 | export const handleCreateNewUser = async (ctx: Context, user: ApiUser) => { 133 | const { tenant } = ctx; 134 | 135 | if (!user.password) { 136 | return { 137 | errorNewUser: MESSAGE.USER.PASSWORD, 138 | newUser: null, 139 | }; 140 | } 141 | 142 | const newUser = new UserModel({ 143 | tenant, 144 | name: user.name, 145 | email: user.email, 146 | password: user.password, 147 | }); 148 | 149 | await newUser.save(); 150 | 151 | if (!newUser) { 152 | return { 153 | errorNewUser: MESSAGE.USER.CREATING, 154 | newUser: null, 155 | }; 156 | } 157 | 158 | return { 159 | errorsNewUser: null, 160 | newUser, 161 | }; 162 | }; 163 | -------------------------------------------------------------------------------- /packages/api/src/app.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa'; 2 | import bodyParser from 'koa-bodyparser'; 3 | import logger from 'koa-logger'; 4 | import Router from 'koa-router'; 5 | import cors from 'koa-cors'; 6 | 7 | import { version } from '../package.json'; 8 | 9 | import { OK } from './common/consts'; 10 | import auth from './auth/auth'; 11 | import authEmail from './api/auth/v1/login/authEmail'; 12 | import authPassword from './api/auth/v1/login/authPassword'; 13 | import userGetAll from './api/user/v1/userGetAll'; 14 | import userGet from './api/user/v1/userGet'; 15 | import { getSwaggerSpec } from './swaggerSpec'; 16 | import userDelete from './api/user/v1/userDelete'; 17 | import { userPost } from './api/user/v1/userPost'; 18 | const app = new Koa(); 19 | 20 | const routerAuth = new Router(); 21 | const routerOpen = new Router(); 22 | 23 | app.use(logger()); 24 | app.use(cors({ maxAge: 86400 })); 25 | app.use(bodyParser()); 26 | 27 | routerOpen.get('/swagger.json', ctx => { 28 | const swaggerSpec = getSwaggerSpec(); 29 | ctx.body = swaggerSpec; 30 | }); 31 | 32 | //Open APIS (APIs that dont need to Authenticate) 33 | routerOpen.get('/api/version', ctx => { 34 | ctx.status = 200; 35 | ctx.body = { 36 | status: OK, 37 | message: version, 38 | }; 39 | }); 40 | 41 | app.use(routerOpen.routes()); 42 | 43 | //Authorized APIs 44 | //Beyond this points APIS need to be Authenticated 45 | routerAuth.use(auth); 46 | 47 | // auth 48 | routerAuth.post('/api/auth/v1/login/email', authEmail); 49 | routerAuth.post('/api/auth/v1/login/password', authPassword); 50 | 51 | // user 52 | routerAuth.get('/api/user/v1/users', userGetAll); 53 | routerAuth.get('/api/user/v1/users/:id', userGet); 54 | routerAuth.delete('/api/user/v1/users/:id', userDelete); 55 | routerAuth.post('/api/user/v1/users', userPost); 56 | 57 | app.use(routerAuth.routes()); 58 | 59 | // Default not found 404 60 | app.use(ctx => { 61 | ctx.status = 404; 62 | }); 63 | 64 | export default app; 65 | -------------------------------------------------------------------------------- /packages/api/src/auth/__tests__/getToken.spec.ts: -------------------------------------------------------------------------------- 1 | import { getToken } from '../getToken'; 2 | import { base64 } from '../base64'; 3 | 4 | it('should return null for invalid token without base64', () => { 5 | const authorization = 'blah'; 6 | const result = getToken(authorization); 7 | 8 | expect(result).toBe(null); 9 | }); 10 | 11 | it('should return null for valid token without base64', () => { 12 | const authorization = 'blah:bleh'; 13 | const result = getToken(authorization); 14 | 15 | expect(result).toBe(null); 16 | }); 17 | 18 | it('should return null for invalid token with base64', () => { 19 | const authorization = base64('blah'); 20 | const result = getToken(authorization); 21 | 22 | expect(result).toBe(null); 23 | }); 24 | 25 | it('should return getToken result for valid token with base64', () => { 26 | const authorization = base64('blah:bleh'); 27 | const result = getToken(authorization); 28 | 29 | expect(result).toEqual({ tenant: 'blah', user: 'bleh' }); 30 | }); 31 | -------------------------------------------------------------------------------- /packages/api/src/auth/auth.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | 3 | import { IUser, ITenant } from '@fp/modules'; 4 | 5 | import { ERROR } from '../common/consts'; 6 | import { config } from '../config'; 7 | 8 | import { getUser } from './sessionManagement'; 9 | 10 | export type AuthContext = { 11 | user: IUser; 12 | tenant: ITenant; 13 | }; 14 | 15 | const auth = async (ctx, next) => { 16 | const { authorization, domainname } = ctx.header; 17 | 18 | const result = await getUser(authorization, domainname); 19 | 20 | const { unauthorized, user, tenant, message } = result; 21 | 22 | if (unauthorized) { 23 | ctx.status = 401; 24 | ctx.body = { 25 | status: ERROR, 26 | message, 27 | }; 28 | 29 | return; 30 | } 31 | 32 | ctx.user = user; 33 | ctx.tenant = tenant; 34 | 35 | await next(); 36 | }; 37 | 38 | export default auth; 39 | 40 | export const generateToken = (tenant: ITenant, user: IUser) => { 41 | return `JWT ${jwt.sign({ tenant: tenant._id, user: user._id }, config.JWT_KEY)}`; 42 | }; 43 | -------------------------------------------------------------------------------- /packages/api/src/auth/base64.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/graphql/graphql-relay-js/blob/4fdadd3bbf3d5aaf66f1799be3e4eb010c115a4a/src/utils/base64.js 2 | /** 3 | * Copyright (c) 2015-present, Facebook, Inc. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | * @flow 9 | */ 10 | 11 | export type Base64String = string; 12 | 13 | export function base64(i: string): Base64String { 14 | return Buffer.from(i, 'utf8').toString('base64'); 15 | } 16 | 17 | export function unbase64(i: Base64String): string { 18 | return Buffer.from(i, 'base64').toString('utf8'); 19 | } 20 | -------------------------------------------------------------------------------- /packages/api/src/auth/getToken.ts: -------------------------------------------------------------------------------- 1 | import { Base64String, unbase64 } from './base64'; 2 | 3 | export type Token = { 4 | tenant: string; 5 | user: string; 6 | }; 7 | 8 | export const getToken = (authorization: Base64String): Token | null => { 9 | const concatToken = unbase64(authorization); 10 | const tokens = concatToken.split(':'); 11 | 12 | if (tokens.length !== 2) { 13 | // console.log('invalid token:', authorization); 14 | return null; 15 | } 16 | 17 | return { 18 | tenant: tokens[0], 19 | user: tokens[1], 20 | }; 21 | }; 22 | 23 | export default getToken; 24 | -------------------------------------------------------------------------------- /packages/api/src/auth/sessionManagement.ts: -------------------------------------------------------------------------------- 1 | import { TenantModel, UserModel } from '@fp/modules'; 2 | 3 | import { ERROR } from '../common/consts'; 4 | 5 | import { MESSAGE } from '../api/ApiHelpers'; 6 | 7 | import getToken from './getToken'; 8 | 9 | export const getUser = async (token?: string, domainName?: string) => { 10 | const tenant = domainName 11 | ? await TenantModel.findOne({ 12 | domainName: domainName, 13 | }) 14 | : null; 15 | 16 | const defaultReturn = { 17 | user: null, 18 | tenant, 19 | unauthorized: false, 20 | }; 21 | 22 | const defaultInvalidToken = { 23 | user: null, 24 | tenant, 25 | unauthorized: true, 26 | }; 27 | 28 | if (!tenant) { 29 | return { 30 | ...defaultInvalidToken, 31 | company: null, 32 | message: MESSAGE.TENANT.NOT_FOUND, 33 | }; 34 | } 35 | 36 | if (tenant && tenant.active === false) { 37 | return { 38 | ...defaultInvalidToken, 39 | company: null, 40 | message: MESSAGE.TENANT.NOT_FOUND, 41 | }; 42 | } 43 | 44 | if (!token || token === 'null') { 45 | return defaultReturn; 46 | } 47 | 48 | try { 49 | const { tenant, user: userId } = getToken(token); 50 | 51 | const user = await UserModel.findOne({ 52 | _id: userId, 53 | tenant, 54 | }); 55 | 56 | // UnAuthorized 57 | if (user == null) { 58 | ctx.status = 401; 59 | ctx.body = { 60 | status: ERROR, 61 | message: MESSAGE.AUTH.UNAUTHORIZED, 62 | }; 63 | 64 | return; 65 | } 66 | 67 | return { 68 | user, 69 | tenant, 70 | }; 71 | } catch (err) { 72 | // eslint-disable-next-line 73 | console.lg('err: ', err); 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /packages/api/src/common/consts.ts: -------------------------------------------------------------------------------- 1 | export const OK = 'OK'; 2 | export const ERROR = 'ERROR'; 3 | -------------------------------------------------------------------------------- /packages/api/src/config.ts: -------------------------------------------------------------------------------- 1 | import { config as configShared } from '@fp/shared'; 2 | 3 | export const config = { 4 | MONGO_URI: process.env.MONGO_URI || configShared.MONGO_URI || 'mongodb://localhost/fullstack_playground', 5 | API_PORT: process.env.API_PORT || configShared.API_PORT || '5002', 6 | JWT_KEY: process.env.JWT_KEY || configShared.JWT_KEY || 'secret_key', 7 | }; 8 | -------------------------------------------------------------------------------- /packages/api/src/database.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import { config } from './config'; 4 | 5 | export const connectDatabase = () => { 6 | return new Promise((resolve, reject) => { 7 | mongoose.Promise = global.Promise; 8 | mongoose.connection 9 | .on('error', error => reject(error)) 10 | // eslint-disable-next-line 11 | .on('close', () => console.log('Database connection closed.')) 12 | .once('open', () => resolve(mongoose.connections[0])); 13 | 14 | mongoose.connect(config.MONGO_URI, { 15 | useNewUrlParser: true, 16 | useCreateIndex: true, 17 | }); 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /packages/api/src/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | import '@babel/polyfill'; 3 | // eslint-disable-next-line 4 | import 'isomorphic-fetch'; 5 | 6 | // eslint-disable-next-line 7 | import app from './app'; 8 | // eslint-disable-next-line 9 | import { config } from './config'; 10 | // eslint-disable-next-line 11 | import { connectDatabase } from './database'; 12 | 13 | (async () => { 14 | try { 15 | // eslint-disable-next-line 16 | await connectDatabase(); 17 | } catch (error) { 18 | // eslint-disable-next-line 19 | console.error('Unable to connect to database'); 20 | 21 | // eslint-disable-next-line 22 | // Exit Process if there is no Database Connection 23 | // eslint-disable-next-line 24 | process.exit(1); 25 | } 26 | // eslint-disable-next-line 27 | await app.listen(config.PORT); 28 | // eslint-disable-next-line 29 | console.log(`API started on port ${config.PORT}`); 30 | })(); 31 | -------------------------------------------------------------------------------- /packages/api/src/routes.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniloab/fullstack-playground/3008654ea161c9dac0555a294b56977d5c5add2a/packages/api/src/routes.ts -------------------------------------------------------------------------------- /packages/api/src/swagger/fpApi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.3", 3 | "info": { 4 | "title": "Fullstack Playground", 5 | "description": "Fullstack Playground api docs", 6 | "version": "1.0.0" 7 | }, 8 | "paths": { 9 | "/api/auth/v1/login/email": { 10 | "post": { 11 | "tags": [ 12 | "auth" 13 | ], 14 | "summary": "Login by Email", 15 | "description": "Endpoint to login with email and validate it", 16 | "requestBody": { 17 | "description": "Data to do login by email", 18 | "required": true, 19 | "content": { 20 | "application/json": { 21 | "schema": { 22 | "type": "object", 23 | "email": { 24 | "type": "string" 25 | } 26 | }, 27 | "example": { 28 | "email": "test@test.com" 29 | } 30 | } 31 | } 32 | }, 33 | "responses": { 34 | "200": { 35 | "description": "Email valid", 36 | "content": { 37 | "application/json": { 38 | "schema": { 39 | "type": "object", 40 | "properties": { 41 | "companyId": { 42 | "type": "string" 43 | }, 44 | "message": { 45 | "type": "string" 46 | } 47 | }, 48 | "example": { 49 | "companyId": "9134e286-6f71-427a-bf00-241681624586", 50 | "message": "Email successful" 51 | } 52 | } 53 | } 54 | } 55 | }, 56 | "400": { 57 | "description": "An error message", 58 | "content": { 59 | "application/json": { 60 | "schema": { 61 | "type": "object", 62 | "properties": { 63 | "status": { 64 | "type": "string" 65 | }, 66 | "message": { 67 | "type": "string" 68 | } 69 | } 70 | } 71 | } 72 | } 73 | } 74 | } 75 | } 76 | } 77 | }, 78 | "components": {}, 79 | "tags": [] 80 | } -------------------------------------------------------------------------------- /packages/api/src/swagger/fpApi.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: Fullstack Playground 4 | description: Fullstack Playground api docs 5 | version: 1.0.0 6 | paths: 7 | /api/auth/v1/login/email: 8 | post: 9 | tags: 10 | - auth 11 | summary: Login by Email 12 | description: Endpoint to login with email and validate it 13 | requestBody: 14 | description: Data to do login by email 15 | required: true 16 | content: 17 | application/json: 18 | schema: 19 | type: object 20 | email: 21 | type: string 22 | example: 23 | email: test@test.com 24 | responses: 25 | "200": 26 | description: Email valid 27 | content: 28 | application/json: 29 | schema: 30 | type: object 31 | properties: 32 | companyId: 33 | type: string 34 | message: 35 | type: string 36 | example: 37 | companyId: 9134e286-6f71-427a-bf00-241681624586 38 | message: Email successful 39 | "400": 40 | description: An error message 41 | content: 42 | application/json: 43 | schema: 44 | type: object 45 | properties: 46 | status: 47 | type: string 48 | message: 49 | type: string 50 | components: {} 51 | tags: [] 52 | -------------------------------------------------------------------------------- /packages/api/src/swagger/swaggerConfig.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | openapi: '3.0.3', 3 | info: { 4 | title: 'Fullstack Playground', 5 | description: 'Fullstack Playground api docs', 6 | version: '1.0.0', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/api/src/swaggerSpec.ts: -------------------------------------------------------------------------------- 1 | import swaggerJSDoc from 'swagger-jsdoc'; 2 | 3 | import { version } from '../package.json'; 4 | 5 | import { config } from './config'; 6 | 7 | export const getSwaggerSpec = () => { 8 | // swagger definition 9 | const swaggerDefinition = { 10 | info: { 11 | title: 'Fullstack Playground API', 12 | version, 13 | description: 'Koa JS rest api', 14 | host: `localhost:${config.PORT}`, 15 | basePath: '/', 16 | }, 17 | }; 18 | 19 | // options for the swagger docs 20 | const options = { 21 | // import swaggerDefinitions 22 | swaggerDefinition: swaggerDefinition, 23 | // path to the API docs 24 | apis: [], 25 | }; 26 | 27 | // initialize swagger-jsdoc 28 | const swaggerSpec = swaggerJSDoc(options); 29 | 30 | return swaggerSpec; 31 | }; 32 | -------------------------------------------------------------------------------- /packages/api/src/types.ts: -------------------------------------------------------------------------------- 1 | export type DeepPartial = { 2 | [P in keyof T]?: T[P] extends (infer U)[] 3 | ? DeepPartial[] 4 | : T[P] extends readonly (infer U)[] 5 | ? readonly DeepPartial[] 6 | : DeepPartial; 7 | }; 8 | -------------------------------------------------------------------------------- /packages/api/test/babel-transformer.js: -------------------------------------------------------------------------------- 1 | const config = require('@fp/babel'); 2 | 3 | const { createTransformer } = require('babel-jest'); 4 | 5 | module.exports = createTransformer({ 6 | ...config, 7 | }); 8 | -------------------------------------------------------------------------------- /packages/api/test/environment/mongodb.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const MongodbMemoryServer = require('mongodb-memory-server-global'); 3 | const NodeEnvironment = require('jest-environment-node'); 4 | 5 | class MongoDbEnvironment extends NodeEnvironment { 6 | constructor(config) { 7 | // console.error('\n# MongoDB Environment Constructor #\n'); 8 | super(config); 9 | this.mongod = new MongodbMemoryServer.default({ 10 | instance: { 11 | // settings here 12 | // dbName is null, so it's random 13 | // dbName: MONGO_DB_NAME, 14 | }, 15 | binary: { 16 | version: '4.0.5', 17 | }, 18 | // debug: true, 19 | autoStart: false, 20 | }); 21 | } 22 | 23 | async setup() { 24 | await super.setup(); 25 | // console.error('\n# MongoDB Environment Setup #\n'); 26 | await this.mongod.start(); 27 | this.global.__MONGO_URI__ = await this.mongod.getConnectionString(); 28 | this.global.__MONGO_DB_NAME__ = await this.mongod.getDbName(); 29 | this.global.__COUNTERS__ = { 30 | user: 0, 31 | company: 0, 32 | }; 33 | } 34 | 35 | async teardown() { 36 | await super.teardown(); 37 | // console.error('\n# MongoDB Environment Teardown #\n'); 38 | await this.mongod.stop(); 39 | this.mongod = null; 40 | this.global = {}; 41 | } 42 | 43 | runScript(script) { 44 | return super.runScript(script); 45 | } 46 | } 47 | 48 | module.exports = MongoDbEnvironment; 49 | -------------------------------------------------------------------------------- /packages/api/test/index.ts: -------------------------------------------------------------------------------- 1 | export { createApiCall, createDeleteApiCall, createGetApiCall } from './restUtils'; 2 | -------------------------------------------------------------------------------- /packages/api/test/restUtils.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | import app from '../src/app'; 4 | 5 | type ApiArgs = { 6 | url: string | null; 7 | authorization: string | null; 8 | payload: {} | null; 9 | domainname: string | null; 10 | }; 11 | 12 | export const createApiCall = async (args: ApiArgs) => { 13 | const { url, authorization, payload: body, domainname = '' } = args; 14 | 15 | const payload = { 16 | ...body, 17 | }; 18 | 19 | const response = await request(app.callback()) 20 | .post(url) 21 | .set({ 22 | Accept: 'application/json', 23 | 'Content-Type': 'application/json', 24 | domainname, 25 | ...(authorization ? { authorization } : {}), 26 | }) 27 | .send(JSON.stringify(payload)); 28 | 29 | return response; 30 | }; 31 | 32 | export const createGetApiCall = async (args: ApiArgs) => { 33 | const { url, authorization, domainname = '' } = args; 34 | 35 | const response = await request(app.callback()) 36 | .get(url) 37 | .set({ 38 | Accept: 'application/json', 39 | 'Content-Type': 'application/json', 40 | domainname, 41 | ...(authorization ? { authorization } : {}), 42 | }) 43 | .send(); 44 | 45 | return response; 46 | }; 47 | 48 | export const createDeleteApiCall = async (args: ApiArgs) => { 49 | const { url, authorization, domainname = '' } = args; 50 | 51 | const response = await request(app.callback()) 52 | .delete(url) 53 | .set({ 54 | Accept: 'application/json', 55 | 'Content-Type': 'application/json', 56 | domainname, 57 | ...(authorization ? { authorization } : {}), 58 | }) 59 | .send(); 60 | 61 | return response; 62 | }; 63 | -------------------------------------------------------------------------------- /packages/api/test/setupTestFramework.js: -------------------------------------------------------------------------------- 1 | // this file is ran right after the test framework is setup for some test file. 2 | require('@babel/polyfill'); 3 | 4 | // jest.mock('graphql-redis-subscriptions'); 5 | 6 | // https://jestjs.io/docs/en/es6-class-mocks#simple-mock-using-module-factory-parameter 7 | 8 | require('jest-fetch-mock').enableMocks(); 9 | 10 | process.env.FEEDBACK_ENV = 'testing'; 11 | process.env.TZ = 'GMT'; 12 | -------------------------------------------------------------------------------- /packages/api/webpack.config.api.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const webpack = require('webpack'); 4 | 5 | const WebpackNodeExternals = require('webpack-node-externals'); 6 | const ReloadServerPlugin = require('reload-server-webpack-plugin'); 7 | 8 | const cwd = process.cwd(); 9 | 10 | module.exports = { 11 | mode: 'development', 12 | devtool: 'cheap-eval-source-map', 13 | entry: { 14 | server: [ 15 | // 'webpack/hot/poll?1000', 16 | './src/index.ts', 17 | ], 18 | }, 19 | output: { 20 | path: path.resolve('build'), 21 | filename: 'server.js', 22 | // https://github.com/webpack/webpack/pull/8642 23 | futureEmitAssets: true, 24 | }, 25 | watch: true, 26 | target: 'node', 27 | node: { 28 | fs: true, 29 | __dirname: true, 30 | }, 31 | externals: [ 32 | WebpackNodeExternals({ 33 | allowlist: ['webpack/hot/poll?1000'], 34 | }), 35 | WebpackNodeExternals({ 36 | modulesDir: path.resolve(__dirname, '../../node_modules'), 37 | allowlist: [/@fp/], 38 | }), 39 | ], 40 | resolve: { 41 | extensions: ['.ts', '.tsx', '.js', '.json', '.mjs'], 42 | }, 43 | module: { 44 | rules: [ 45 | { 46 | test: /\.mjs$/, 47 | include: /node_modules/, 48 | type: 'javascript/auto', 49 | }, 50 | { 51 | test: /\.(js|jsx|ts|tsx)?$/, 52 | use: { 53 | loader: 'babel-loader', 54 | }, 55 | exclude: [/node_modules/], 56 | include: [path.join(cwd, 'src'), path.join(cwd, '../')], 57 | }, 58 | ], 59 | }, 60 | plugins: [ 61 | new ReloadServerPlugin({ 62 | script: path.resolve('build', 'server.js'), 63 | }), 64 | new webpack.HotModuleReplacementPlugin(), 65 | new webpack.DefinePlugin({ 66 | 'process.env.NODE_ENV': JSON.stringify('development'), 67 | }), 68 | ], 69 | }; 70 | -------------------------------------------------------------------------------- /packages/babel/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-flow', 4 | [ 5 | '@babel/preset-env', 6 | { 7 | targets: { 8 | node: 'current', 9 | }, 10 | }, 11 | ], 12 | '@babel/preset-react', 13 | '@babel/preset-typescript', 14 | ], 15 | plugins: [ 16 | '@babel/plugin-proposal-class-properties', 17 | '@babel/plugin-proposal-export-default-from', 18 | '@babel/plugin-proposal-export-namespace-from', 19 | // comment this line to test worker:webpack migrations 20 | 'require-context-hook', 21 | '@babel/plugin-proposal-nullish-coalescing-operator', 22 | '@babel/plugin-proposal-optional-chaining', 23 | ], 24 | }; -------------------------------------------------------------------------------- /packages/babel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fp/babel", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@babel/cli": "7.14.3", 8 | "@babel/core": "7.14.0", 9 | "@babel/node": "7.14.9", 10 | "@babel/plugin-proposal-class-properties": "7.13.0", 11 | "@babel/plugin-proposal-export-default-from": "7.12.13", 12 | "@babel/plugin-proposal-export-namespace-from": "7.12.13", 13 | "@babel/plugin-proposal-nullish-coalescing-operator": "7.14.5", 14 | "@babel/plugin-proposal-optional-chaining": "7.14.5", 15 | "@babel/plugin-transform-flow-strip-types": "7.16.7", 16 | "@babel/preset-env": "7.14.1", 17 | "@babel/preset-flow": "7.13.13", 18 | "@babel/preset-react": "7.16.7", 19 | "@babel/preset-typescript": "7.7.7", 20 | "babel-plugin-require-context-hook": "1.0.0" 21 | }, 22 | "devDependencies": { 23 | "@types/babel__core": "7.1.18" 24 | }, 25 | "main": "index.js" 26 | } 27 | -------------------------------------------------------------------------------- /packages/babel/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | } -------------------------------------------------------------------------------- /packages/modules/README.md: -------------------------------------------------------------------------------- 1 | # @fp/modules 2 | 3 | All fullstack playground models/modules to be consumed by Api, GraphQL and so on 4 | -------------------------------------------------------------------------------- /packages/modules/babel.config.js: -------------------------------------------------------------------------------- 1 | const config = require('@fp/babel'); 2 | 3 | module.exports = config; 4 | -------------------------------------------------------------------------------- /packages/modules/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fp/modules", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "Danilo Assis", 6 | "license": "MIT", 7 | "main": "dist/index.js", 8 | "module": "src/index.ts", 9 | "devDependencies": { 10 | "@fp/babel": "*", 11 | "@types/babel__core": "7.1.18" 12 | }, 13 | "scripts": { 14 | "build": "babel src --extensions \".es6,.js,.es,.jsx,.mjs,.ts,.tsx\" --ignore *.spec.js --out-dir dist --copy-files --source-maps --verbose" 15 | }, 16 | "dependencies": { 17 | "bcryptjs": "^2.4.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/modules/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as TenantModel, ITenant } from './tenant/TenantModel'; 2 | export { createTenant } from './tenant/__fixtures__/createTenant'; 3 | 4 | export { default as UserModel, IUser } from './user/UserModel'; 5 | export { createUser } from './user/__fixtures__/createUser'; 6 | -------------------------------------------------------------------------------- /packages/modules/src/tenant/TenantModel.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Model } from 'mongoose'; 2 | 3 | const Schema = new mongoose.Schema( 4 | { 5 | name: { 6 | type: String, 7 | description: 'Tenant name', 8 | required: true, 9 | trim: true, 10 | es_indexed: true, 11 | }, 12 | domainName: { 13 | type: String, 14 | description: 'The name of the domain to be used on application', 15 | unique: true, 16 | lowercase: true, 17 | trim: true, 18 | es_indexed: true, 19 | }, 20 | active: { 21 | type: Boolean, 22 | default: true, 23 | required: true, 24 | description: 'Defines if this company is active or not', 25 | es_indexed: true, 26 | }, 27 | }, 28 | { 29 | timestamps: { 30 | createdAt: 'createdAt', 31 | updatedAt: 'updatedAt', 32 | }, 33 | collection: 'Tenant', 34 | }, 35 | ); 36 | 37 | export interface ITenant extends Document { 38 | name: string; 39 | domainName: string; 40 | active: boolean; 41 | createdAt: Date; 42 | updatedAt: Date; 43 | } 44 | 45 | const TenantModel: Model = mongoose.model('Tenant', Schema); 46 | 47 | export default TenantModel; 48 | -------------------------------------------------------------------------------- /packages/modules/src/tenant/__fixtures__/createTenant.ts: -------------------------------------------------------------------------------- 1 | import { ITenant, TenantModel } from '@fp/modules'; 2 | 3 | import { DeepPartial } from '@fp/api/src/types'; 4 | 5 | export const createTenant = async (args: DeepPartial = {}) => { 6 | const { name, domainName } = args; 7 | const n = (global.__COUNTERS__.company += 1); 8 | 9 | const company = await new TenantModel({ 10 | name: name ?? `Awesome Tenant ${n}`, 11 | domainName: domainName ?? 'test.application.com', 12 | active: true, 13 | }).save(); 14 | 15 | return company; 16 | }; 17 | -------------------------------------------------------------------------------- /packages/modules/src/user/UserModel.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Model, Types } from 'mongoose'; 2 | import bcrypt from 'bcryptjs'; 3 | 4 | const { ObjectId } = mongoose.Schema.Types; 5 | 6 | const UserSchema = new mongoose.Schema( 7 | { 8 | name: { 9 | type: String, 10 | required: true, 11 | }, 12 | email: { 13 | type: String, 14 | required: true, 15 | index: true, 16 | }, 17 | password: { 18 | type: String, 19 | hidden: true, 20 | }, 21 | tenant: { 22 | type: ObjectId, 23 | ref: 'Tenant', 24 | description: 'Tenant that this document belongs to', 25 | required: true, 26 | index: true, 27 | es_indexed: true, 28 | }, 29 | removedAt: { 30 | type: Date, 31 | index: true, 32 | default: null, 33 | 34 | es_indexed: true, 35 | }, 36 | }, 37 | { 38 | timestamps: { 39 | createdAt: 'createdAt', 40 | updatedAt: 'updatedAt', 41 | }, 42 | collection: 'User', 43 | }, 44 | ); 45 | 46 | export interface IUser extends Document { 47 | name: string; 48 | email: string; 49 | password: string; 50 | tenant: Types.ObjectId; 51 | removedAt: Date; 52 | authenticate: (plainTextPassword: string) => boolean; 53 | encryptPassword: (password: string | undefined) => string; 54 | createdAt: Date; 55 | updatedAt: Date; 56 | } 57 | 58 | UserSchema.pre('save', function encryptPasswordHook(next) { 59 | // Hash the password 60 | if (this.isModified('password')) { 61 | this.password = this.encryptPassword(this.password); 62 | } 63 | 64 | return next(); 65 | }); 66 | 67 | UserSchema.methods = { 68 | authenticate(plainTextPassword: string) { 69 | return bcrypt.compareSync(plainTextPassword, this.password); 70 | }, 71 | encryptPassword(password: string) { 72 | return bcrypt.hashSync(password, 8); 73 | }, 74 | }; 75 | 76 | const UserModel: Model = mongoose.model('User', UserSchema); 77 | 78 | export default UserModel; 79 | -------------------------------------------------------------------------------- /packages/modules/src/user/__fixtures__/createUser.ts: -------------------------------------------------------------------------------- 1 | import { getOrCreate } from '@fp/test'; 2 | import { DeepPartial } from '@fp/types'; 3 | 4 | import UserModel, { IUser } from '../UserModel'; 5 | import TenantModel from '../../tenant/TenantModel'; 6 | import { createTenant } from '../../tenant/__fixtures__/createTenant'; 7 | 8 | type CreateUserArgs = DeepPartial; 9 | export const createUser = async (args: CreateUserArgs = {}): Promise => { 10 | // eslint-disable-next-line 11 | let { email, tenant, password, ...restArgs } = args; 12 | 13 | // TODO - migrate to getCounter 14 | // const n = getCounter('user'); 15 | const n = (global.__COUNTERS__.user += 1); 16 | 17 | if (!email) { 18 | email = `user${n}@example.com`; 19 | } 20 | 21 | if (!tenant) { 22 | tenant = await getOrCreate(TenantModel, createTenant); 23 | } 24 | 25 | return new UserModel({ 26 | name: args.name || `Normal user ${n}`, 27 | password: password || '123456', 28 | email, 29 | tenant, 30 | ...restArgs, 31 | }).save(); 32 | }; 33 | -------------------------------------------------------------------------------- /packages/notification/.env.example: -------------------------------------------------------------------------------- 1 | # AWS Keys 2 | AWS_REGION= -------------------------------------------------------------------------------- /packages/notification/README.md: -------------------------------------------------------------------------------- 1 | # @fp/notification 2 | 3 | Notification utilities to make testing easier 4 | 5 | ## What you can find here 6 | By now, it only disponible a email function used on this blog post. 7 | 8 | But, feel free to implement any function related to notifications. -------------------------------------------------------------------------------- /packages/notification/babel.config.js: -------------------------------------------------------------------------------- 1 | const config = require('@fp/babel'); 2 | 3 | module.exports = config; 4 | -------------------------------------------------------------------------------- /packages/notification/jest.config.js: -------------------------------------------------------------------------------- 1 | const pack = require('./package'); 2 | 3 | module.exports = { 4 | displayName: pack.name, 5 | name: pack.name, 6 | // testEnvironment: '/test/environment/mongodb', 7 | testPathIgnorePatterns: ['/node_modules/', './dist'], 8 | coverageReporters: ['lcov', 'html'], 9 | // setupFilesAfterEnv: ['/test/setupTestFramework.js'], 10 | resetModules: false, 11 | reporters: ['default', 'jest-junit'], 12 | transform: { 13 | '^.+\\.(js|ts|tsx)?$': '/test/babel-transformer', 14 | }, 15 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(js|ts|tsx)?$', 16 | moduleFileExtensions: ['ts', 'js', 'tsx', 'json'], 17 | }; 18 | -------------------------------------------------------------------------------- /packages/notification/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fp/notification", 3 | "version": "1.0.0", 4 | "description": "notification package", 5 | "main": "index.js", 6 | "author": "Danilo Assis", 7 | "license": "MIT", 8 | "private": true, 9 | "dependencies": { 10 | "@babel/polyfill": "^7.8.7", 11 | "aws-sdk": "^2.776.0", 12 | "html-to-text": "^7.1.1" 13 | }, 14 | "devDependencies": { 15 | "@fp/babel": "*", 16 | "@fp/modules": "*" 17 | }, 18 | "scripts": { 19 | "start": "webpack --watch --progress --config webpack.config.api.js" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/notification/src/email/__tests__/sendEmail.spec.ts: -------------------------------------------------------------------------------- 1 | import SES from 'aws-sdk/clients/ses'; 2 | 3 | import { sendEmail } from '../sendEmail'; 4 | import { getHtmlEmail } from '../htmlEmail'; 5 | 6 | type SesCall = { 7 | Destination: { 8 | ToAddresses: string[]; 9 | }; 10 | Message: { 11 | Body: { 12 | Html: { 13 | Data: string; 14 | }; 15 | Text: { 16 | Data: string; 17 | }; 18 | }; 19 | Subject: { 20 | Data: string; 21 | }; 22 | }; 23 | Source: string; 24 | ReplyToAddresses: string[]; 25 | ReturnPath: string; 26 | }; 27 | 28 | export const getEmailFromSes = (): SesCall[] => { 29 | let emails: SesCall = []; 30 | 31 | SES.mock.results.map((result) => { 32 | result.value.sendEmail.mock.calls.map((call) => { 33 | if (Array.isArray(call) && call.length > 0) { 34 | emails = [...emails, call[0]]; 35 | } 36 | }); 37 | }); 38 | 39 | return emails; 40 | }; 41 | 42 | it.skip('should send email and validate href token', async () => { 43 | const token = 'token'; 44 | 45 | const url = `https://fullstackplayground/login?token=${token}`; 46 | 47 | const emailName = 'Danilo Assis'; 48 | 49 | const payload = { 50 | email: 'hi@daniloassis.dev', 51 | name: emailName, 52 | subject: 'Fullstack Playground - Login', 53 | emailHtml: getHtmlEmail({ name: emailName, url }), 54 | }; 55 | 56 | await sendEmail(payload); 57 | 58 | const emails = getEmailFromSes(); 59 | 60 | expect(emails.length).toBe(1); 61 | 62 | // expect(response.body.errors).toBeUndefined(); 63 | // expect(response.body.data.version).toBe(version); 64 | }); 65 | -------------------------------------------------------------------------------- /packages/notification/src/email/htmlEmail.tsx: -------------------------------------------------------------------------------- 1 | type HtmlCompositionProps = { 2 | name: string; 3 | url: string; 4 | }; 5 | 6 | export const getHtmlEmail = ({ name, url }: HtmlCompositionProps): string => { 7 | return ` 8 | 9 | 10 | 11 | 80 | 81 | 82 |
12 | 16 | 17 | 18 | 76 | 77 | 78 |
19 | 20 | 21 | 22 | 36 | 37 | 38 |
23 | 24 | 25 | 26 | 32 | 33 | 34 |
27 |

28 | Hi, ${name}. Welcome to 29 | Fullstack Playground! 30 |

31 |
35 |
39 | 40 | 41 | 42 | 55 | 56 | 57 |
43 | 44 | 45 | 46 | 51 | 52 | 53 |
47 |

48 | Please click the button below to sign in{" "} 49 |

50 |
54 |
58 | 59 | 60 | 61 | 72 | 73 | 74 |
62 | 69 | Sign In 70 | 71 |
75 |
79 |
`; 83 | }; 84 | -------------------------------------------------------------------------------- /packages/notification/src/email/sendEmail.ts: -------------------------------------------------------------------------------- 1 | import SES, { PromiseResult } from 'aws-sdk/clients/ses'; 2 | 3 | // eslint-disable-next-line 4 | import AWS from 'aws-sdk'; 5 | import HTMLToText from 'html-to-text'; 6 | 7 | export type sendEmailPayload = { 8 | name?: string; 9 | email?: string; 10 | emailHtml?: string; 11 | subject: string; 12 | }; 13 | 14 | export const sendEmail = async (payload: sendEmailPayload): Promise> => { 15 | const { email: rawEmail, subject, emailHtml, name } = payload; 16 | 17 | const email = rawEmail.replace(/(\+\w+)/, ''); 18 | 19 | const ses = new SES(); 20 | const plainText = HTMLToText.fromString(emailHtml, { 21 | ignoreImage: true, 22 | }); 23 | 24 | const params = { 25 | Destination: { 26 | ToAddresses: [email], 27 | }, 28 | Message: { 29 | Body: { 30 | Html: { 31 | Data: emailHtml, 32 | }, 33 | Text: { 34 | Data: plainText, 35 | }, 36 | }, 37 | Subject: { 38 | Data: subject, 39 | }, 40 | }, 41 | Source: `${name} <${email}>`, 42 | ReplyToAddresses: [email], 43 | ReturnPath: email, 44 | }; 45 | 46 | // eslint-disable-next-line 47 | // set region if not set (as not set by the SDK by default) 48 | // if (!AWS.config.region) { 49 | // AWS.config.update({ 50 | // region: 'eu-west-1', 51 | // }); 52 | // } 53 | 54 | try { 55 | return await ses.sendEmail(params).promise(); 56 | } catch (err) { 57 | // eslint-disable-next-line 58 | console.log('Error when sending email with ses:', err); 59 | 60 | return err.toString(); 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /packages/notification/src/index.ts: -------------------------------------------------------------------------------- 1 | export { sendEmail } from './email/sendEmail'; 2 | -------------------------------------------------------------------------------- /packages/notification/test/babel-transformer.js: -------------------------------------------------------------------------------- 1 | const config = require('@fp/babel'); 2 | 3 | const { createTransformer } = require('babel-jest'); 4 | 5 | module.exports = createTransformer({ 6 | ...config, 7 | }); 8 | -------------------------------------------------------------------------------- /packages/server/.env.example: -------------------------------------------------------------------------------- 1 | MONGO_URI= 2 | SERVER_PORT= 3 | JWT_KEY= -------------------------------------------------------------------------------- /packages/server/babel.config.js: -------------------------------------------------------------------------------- 1 | const config = require('@fp/babel'); 2 | 3 | module.exports = config; 4 | -------------------------------------------------------------------------------- /packages/server/config.ts: -------------------------------------------------------------------------------- 1 | import { config as configShared } from '@fp/shared'; 2 | 3 | export const config = { 4 | MONGO_URI: process.env.MONGO_URI || configShared.MONGO_URI || 'mongodb://localhost/fullstack_playground', 5 | SERVER_PORT: process.env.SERVER_PORT || configShared.SERVER_PORT || '5001', 6 | JWT_KEY: process.env.JWT_KEY || configShared.JWT_KEY || 'secret_key', 7 | }; 8 | -------------------------------------------------------------------------------- /packages/server/jest.config.js: -------------------------------------------------------------------------------- 1 | const pack = require('./package'); 2 | 3 | module.exports = { 4 | displayName: pack.name, 5 | name: pack.name, 6 | testEnvironment: '/test/environment/mongodb', 7 | testPathIgnorePatterns: ['/node_modules/', './dist'], 8 | coverageReporters: ['lcov', 'html'], 9 | setupFilesAfterEnv: ['/test/setupTestFramework.js'], 10 | resetModules: false, 11 | reporters: ['default', 'jest-junit'], 12 | transform: { 13 | '^.+\\.(js|ts|tsx)?$': '/test/babel-transformer', 14 | }, 15 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(js|ts|tsx)?$', 16 | moduleFileExtensions: ['ts', 'js', 'tsx', 'json'], 17 | }; 18 | -------------------------------------------------------------------------------- /packages/server/junit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fp/server", 3 | "version": "1.0.0", 4 | "description": "server package", 5 | "main": "index.js", 6 | "author": "Danilo Assis", 7 | "license": "MIT", 8 | "private": true, 9 | "dependencies": { 10 | "@babel/polyfill": "^7.8.7", 11 | "dotenv-safe": "^8.2.0", 12 | "graphql-playground-middleware": "^1.1.2", 13 | "graphql-relay": "^0.6.0", 14 | "isomorphic-fetch": "^3.0.0", 15 | "koa": "^2.12.0", 16 | "koa-bodyparser": "^4.3.0", 17 | "koa-convert": "^2.0.0", 18 | "koa-cors": "^0.0.16", 19 | "koa-graphql": "^0.12.0", 20 | "koa-logger": "^3.2.1", 21 | "koa-multer": "^1.0.2", 22 | "koa-router": "^10.0.0", 23 | "mongoose": "^5.9.14", 24 | "swagger-jsdoc": "^6.1.0", 25 | "yup": "^0.29.1" 26 | }, 27 | "devDependencies": { 28 | "@fp/babel": "*", 29 | "@fp/modules": "*", 30 | "@fp/shared": "*", 31 | "babel-loader": "^8.0.6", 32 | "reload-server-webpack-plugin": "^1.0.1", 33 | "webpack": "4.46.0", 34 | "webpack-cli": "4.7.2", 35 | "webpack-node-externals": "3.0.0", 36 | "webpack-plugin-serve": "1.4.1" 37 | }, 38 | "scripts": { 39 | "start": "webpack --watch --progress --config webpack.config.server.js" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/server/src/__tests__/app-server.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | import { clearDbAndRestartCounters, connectMongoose, disconnectMongoose } from '@fp/test'; 4 | 5 | import app from '../app'; 6 | import { version } from '../../package.json'; 7 | 8 | beforeAll(connectMongoose); 9 | 10 | beforeEach(clearDbAndRestartCounters); 11 | 12 | afterAll(disconnectMongoose); 13 | 14 | it('should return version', async () => { 15 | // language=GraphQL 16 | const query = ` 17 | query Q { 18 | version 19 | } 20 | `; 21 | 22 | const variables = {}; 23 | 24 | const payload = { 25 | query, 26 | variables, 27 | }; 28 | 29 | const response = await request(app.callback()) 30 | .post('/graphql') 31 | .set({ 32 | Accept: 'application/json', 33 | 'Content-Type': 'application/json', 34 | }) 35 | .send(JSON.stringify(payload)); 36 | 37 | expect(response.body.errors).toBeUndefined(); 38 | expect(response.body.data.version).toBe(version); 39 | }); 40 | -------------------------------------------------------------------------------- /packages/server/src/app.ts: -------------------------------------------------------------------------------- 1 | import Koa, { Request, Response } from 'koa'; 2 | 3 | import convert from 'koa-convert'; 4 | 5 | import bodyParser from 'koa-bodyparser'; 6 | import koaLogger from 'koa-logger'; 7 | import { koaPlayground } from 'graphql-playground-middleware'; 8 | import Router from 'koa-router'; 9 | import graphqlHttp from 'koa-graphql'; 10 | 11 | import { config } from '../config'; 12 | 13 | import { schema } from './schema/schema'; 14 | 15 | const app = new Koa(); 16 | app.keys = [config.JWT_KEY]; 17 | const router = new Router(); 18 | 19 | // needed for sentry to log data correctly 20 | app.use(bodyParser()); 21 | 22 | app.use(koaLogger()); 23 | 24 | router.all( 25 | '/playground', 26 | koaPlayground({ 27 | endpoint: '/graphql', 28 | }), 29 | ); 30 | 31 | router.all( 32 | '/graphql', 33 | convert( 34 | graphqlHttp(async (request: Request, ctx: Response, koaContext) => { 35 | return { 36 | graphiql: config.NODE_ENV !== 'production', 37 | schema, 38 | koaContext, 39 | }; 40 | }), 41 | ), 42 | ); 43 | 44 | app.use(router.routes()).use(router.allowedMethods()); 45 | 46 | export default app; 47 | -------------------------------------------------------------------------------- /packages/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill'; 2 | import { createServer } from 'http'; 3 | 4 | import { connectDatabase } from '@fp/shared'; 5 | 6 | import { config } from '../config'; 7 | 8 | import app from './app'; 9 | 10 | const runServer = async () => { 11 | try { 12 | // eslint-disable-next-line 13 | console.log('connecting to database...'); 14 | await connectDatabase(); 15 | } catch (error) { 16 | // eslint-disable-next-line 17 | console.log('Could not connect to database', { error }); 18 | throw error; 19 | } 20 | 21 | const server = createServer(app.callback()); 22 | 23 | server.listen(config.SERVER_PORT, () => { 24 | // eslint-disable-next-line 25 | console.log(`Server started on port :${config.SERVER_PORT}`); 26 | // eslint-disable-next-line 27 | console.log(`GraphQL Fullstack Playground available at /playground on port ${config.SERVER_PORT}`); 28 | }); 29 | }; 30 | 31 | (async () => { 32 | // eslint-disable-next-line 33 | console.log('server starting...'); 34 | 35 | await runServer(); 36 | })(); 37 | -------------------------------------------------------------------------------- /packages/server/src/schema/QueryType.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLString } from 'graphql'; 2 | import { globalIdField } from 'graphql-relay'; 3 | 4 | import { version } from '../../package.json'; 5 | import { GraphQLContext } from '../types'; 6 | 7 | const QueryType = new GraphQLObjectType, GraphQLContext>({ 8 | name: 'Query', 9 | description: 'The root of all... queries', 10 | fields: () => ({ 11 | id: globalIdField('Query'), 12 | version: { 13 | type: GraphQLString, 14 | resolve: () => version, 15 | }, 16 | }), 17 | }); 18 | 19 | export default QueryType; 20 | -------------------------------------------------------------------------------- /packages/server/src/schema/schema.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLSchema } from 'graphql'; 2 | 3 | // import MutationType from './MutationType'; 4 | import QueryType from './QueryType'; 5 | 6 | const _schema = new GraphQLSchema({ 7 | query: QueryType, 8 | // mutation: MutationType, 9 | }); 10 | 11 | export const schema = _schema; 12 | -------------------------------------------------------------------------------- /packages/server/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa'; 2 | export type GraphQLContext = { 3 | koaContext: Context; 4 | }; 5 | -------------------------------------------------------------------------------- /packages/server/test/babel-transformer.js: -------------------------------------------------------------------------------- 1 | const config = require('@fp/babel'); 2 | 3 | const { createTransformer } = require('babel-jest'); 4 | 5 | module.exports = createTransformer({ 6 | ...config, 7 | }); 8 | -------------------------------------------------------------------------------- /packages/server/test/environment/mongodb.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const MongodbMemoryServer = require('mongodb-memory-server-global'); 3 | const NodeEnvironment = require('jest-environment-node'); 4 | 5 | class MongoDbEnvironment extends NodeEnvironment { 6 | constructor(config) { 7 | // console.error('\n# MongoDB Environment Constructor #\n'); 8 | super(config); 9 | this.mongod = new MongodbMemoryServer.default({ 10 | instance: { 11 | // settings here 12 | // dbName is null, so it's random 13 | // dbName: MONGO_DB_NAME, 14 | }, 15 | binary: { 16 | version: '4.0.5', 17 | }, 18 | // debug: true, 19 | autoStart: false, 20 | }); 21 | } 22 | 23 | async setup() { 24 | await super.setup(); 25 | // console.error('\n# MongoDB Environment Setup #\n'); 26 | await this.mongod.start(); 27 | this.global.__MONGO_URI__ = await this.mongod.getConnectionString(); 28 | this.global.__MONGO_DB_NAME__ = await this.mongod.getDbName(); 29 | this.global.__COUNTERS__ = { 30 | user: 0, 31 | company: 0, 32 | }; 33 | } 34 | 35 | async teardown() { 36 | await super.teardown(); 37 | // console.error('\n# MongoDB Environment Teardown #\n'); 38 | await this.mongod.stop(); 39 | this.mongod = null; 40 | this.global = {}; 41 | } 42 | 43 | runScript(script) { 44 | return super.runScript(script); 45 | } 46 | } 47 | 48 | module.exports = MongoDbEnvironment; 49 | -------------------------------------------------------------------------------- /packages/server/test/index.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /packages/server/test/setup.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { join } = require('path'); 3 | 4 | const MMS = require('mongodb-memory-server-global'); 5 | 6 | const cwd = process.cwd(); 7 | 8 | const globalConfigPath = join(cwd, 'globalConfig.json'); 9 | 10 | // eslint-disable-next-line 11 | const { default: MongodbMemoryServer, MongoMemoryReplSet } = MMS; 12 | 13 | const mongod = new MongodbMemoryServer({ 14 | binary: { 15 | version: '4.2.7', 16 | skipMD5: true, 17 | }, 18 | // debug: true, 19 | autoStart: false, 20 | }); 21 | 22 | module.exports = async () => { 23 | if (!mongod.isRunning) { 24 | await mongod.start(); 25 | } 26 | 27 | const mongoConfig = { 28 | mongoUri: await mongod.getUri(), 29 | }; 30 | 31 | // save mongo uri to be reused in each test 32 | fs.writeFileSync(globalConfigPath, JSON.stringify(mongoConfig)); 33 | 34 | global.__MONGOD__ = mongod; 35 | }; 36 | -------------------------------------------------------------------------------- /packages/server/test/setupTestFramework.js: -------------------------------------------------------------------------------- 1 | // this file is ran right after the test framework is setup for some test file. 2 | require('@babel/polyfill'); 3 | 4 | // jest.mock('graphql-redis-subscriptions'); 5 | 6 | // https://jestjs.io/docs/en/es6-class-mocks#simple-mock-using-module-factory-parameter 7 | 8 | require('jest-fetch-mock').enableMocks(); 9 | 10 | process.env.FEEDBACK_ENV = 'testing'; 11 | process.env.TZ = 'GMT'; 12 | -------------------------------------------------------------------------------- /packages/server/test/teardown.js: -------------------------------------------------------------------------------- 1 | module.exports = async () => { 2 | await global.__MONGOD__.stop(); 3 | }; 4 | -------------------------------------------------------------------------------- /packages/server/webpack.config.server.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const webpack = require('webpack'); 4 | 5 | const WebpackNodeExternals = require('webpack-node-externals'); 6 | const ReloadServerPlugin = require('reload-server-webpack-plugin'); 7 | 8 | const cwd = process.cwd(); 9 | 10 | module.exports = { 11 | mode: 'development', 12 | devtool: 'cheap-eval-source-map', 13 | entry: { 14 | server: [ 15 | // 'webpack/hot/poll?1000', 16 | './src/index.ts', 17 | ], 18 | }, 19 | output: { 20 | path: path.resolve('build'), 21 | filename: 'server.js', 22 | // https://github.com/webpack/webpack/pull/8642 23 | futureEmitAssets: true, 24 | }, 25 | watch: true, 26 | target: 'node', 27 | node: { 28 | fs: true, 29 | __dirname: true, 30 | }, 31 | externals: [ 32 | WebpackNodeExternals({ 33 | allowlist: ['webpack/hot/poll?1000'], 34 | }), 35 | WebpackNodeExternals({ 36 | modulesDir: path.resolve(__dirname, '../../node_modules'), 37 | allowlist: [/@fp/], 38 | }), 39 | ], 40 | resolve: { 41 | extensions: ['.ts', '.tsx', '.js', '.json', '.mjs'], 42 | }, 43 | module: { 44 | rules: [ 45 | { 46 | test: /\.mjs$/, 47 | include: /node_modules/, 48 | type: 'javascript/auto', 49 | }, 50 | { 51 | test: /\.(js|jsx|ts|tsx)?$/, 52 | use: { 53 | loader: 'babel-loader', 54 | }, 55 | exclude: [/node_modules/], 56 | include: [path.join(cwd, 'src'), path.join(cwd, '../')], 57 | }, 58 | ], 59 | }, 60 | plugins: [ 61 | new ReloadServerPlugin({ 62 | script: path.resolve('build', 'server.js'), 63 | }), 64 | new webpack.HotModuleReplacementPlugin(), 65 | new webpack.DefinePlugin({ 66 | 'process.env.NODE_ENV': JSON.stringify('development'), 67 | }), 68 | ], 69 | }; 70 | -------------------------------------------------------------------------------- /packages/shared/babel.config.js: -------------------------------------------------------------------------------- 1 | const config = require('@fp/babel'); 2 | 3 | module.exports = config; 4 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fp/shared", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "dotenv-safe": "^6.1.0", 6 | "graphql": "15.5.1", 7 | "mongoose": "5.13.5", 8 | "pretty-format": "26.6.2" 9 | }, 10 | "main": "dist/index.js", 11 | "module": "src/index.ts", 12 | "scripts": { 13 | "build": "rm -rf dist/* && babel src --extensions \".es6,.js,.es,.jsx,.mjs,.ts,.tsx\" --ignore *.spec.js --out-dir dist --copy-files --source-maps --verbose" 14 | }, 15 | "sideEffects": false 16 | } 17 | -------------------------------------------------------------------------------- /packages/shared/src/config.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | MONGO_URI: process.env.MONGO_URI || 'mongodb://localhost/fullstack_playground', 3 | SERVER_PORT: process.env.SERVER_PORT || '5001', 4 | API_PORT: process.env.SERVER_PORT || '5002', 5 | JWT_KEY: process.env.JWT_KEY || 'secret_key', 6 | }; 7 | -------------------------------------------------------------------------------- /packages/shared/src/index.ts: -------------------------------------------------------------------------------- 1 | export { connectDatabase } from './mongo'; 2 | export { config } from './config'; 3 | -------------------------------------------------------------------------------- /packages/shared/src/mongo.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import { config } from './config'; 4 | 5 | export const connectDatabase = () => { 6 | return new Promise((resolve, reject) => { 7 | mongoose.Promise = global.Promise; 8 | mongoose.connection 9 | .on('error', error => reject(error)) 10 | // eslint-disable-next-line 11 | .on('close', () => console.log('Database connection closed.')) 12 | .once('open', () => resolve(mongoose.connections[0])); 13 | 14 | mongoose.connect(config.MONGO_URI, { 15 | useNewUrlParser: true, 16 | useCreateIndex: true, 17 | }); 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /packages/test/README.md: -------------------------------------------------------------------------------- 1 | # @fp/test 2 | 3 | Test utilities to make testing easier -------------------------------------------------------------------------------- /packages/test/babel.config.js: -------------------------------------------------------------------------------- 1 | const config = require('@fp/babel'); 2 | 3 | module.exports = config; 4 | -------------------------------------------------------------------------------- /packages/test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fp/test", 3 | "version": "1.0.0", 4 | "main": "src/index.ts", 5 | "module": "src/index.ts", 6 | "devDependencies": { 7 | "@fp/babel": "*", 8 | "@fp/types": "*" 9 | }, 10 | "scripts": { 11 | "build": "babel src --extensions \".es6,.js,.es,.jsx,.mjs,.ts,.tsx\" --ignore *.spec.js --out-dir dist --copy-files --source-maps --verbose" 12 | }, 13 | "dependencies": { 14 | "mongoose": "^5.9.20" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/test/src/babel-transformer.js: -------------------------------------------------------------------------------- 1 | const config = require('@fp/babel'); 2 | 3 | const { createTransformer } = require('babel-jest'); 4 | 5 | module.exports = createTransformer({ 6 | ...config, 7 | }); 8 | -------------------------------------------------------------------------------- /packages/test/src/clearDatabase.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import { restartCounters } from './counters'; 4 | 5 | export async function clearDatabase() { 6 | await mongoose.connection.db.dropDatabase(); 7 | } 8 | 9 | export async function clearDbAndRestartCounters() { 10 | await clearDatabase(); 11 | restartCounters(); 12 | } 13 | -------------------------------------------------------------------------------- /packages/test/src/connectMongoose.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const mongooseOptions = { 4 | autoIndex: false, 5 | autoReconnect: false, 6 | connectTimeoutMS: 10000, 7 | useNewUrlParser: true, 8 | useCreateIndex: true, 9 | }; 10 | 11 | export async function connectMongoose() { 12 | jest.setTimeout(20000); 13 | return mongoose.connect(global.__MONGO_URI__, { 14 | ...mongooseOptions, 15 | dbName: global.__MONGO_DB_NAME__, 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /packages/test/src/counters.ts: -------------------------------------------------------------------------------- 1 | export const getCounter = (key: string) => { 2 | if (key in global.__COUNTERS__) { 3 | global.__COUNTERS__[key]++; 4 | 5 | return global.__COUNTERS__[key]; 6 | } 7 | 8 | global.__COUNTERS__[key] = 0; 9 | 10 | return global.__COUNTERS__[key]; 11 | }; 12 | 13 | export const restartCounters = () => { 14 | global.__COUNTERS__ = Object.keys(global.__COUNTERS__).reduce((prev, curr) => ({ ...prev, [curr]: 0 }), {}); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/test/src/createResource/getOrCreate.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'mongoose'; 2 | 3 | export const getOrCreate = async (model: Model, createFn: () => any) => { 4 | const data = await model.findOne().lean(); 5 | 6 | if (data) { 7 | return data; 8 | } 9 | 10 | return createFn(); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/test/src/disconnectMongoose.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | export async function disconnectMongoose() { 4 | await mongoose.disconnect(); 5 | // dumb mongoose 6 | mongoose.connections.forEach(connection => { 7 | const modelNames = Object.keys(connection.models); 8 | 9 | modelNames.forEach(modelName => { 10 | delete connection.models[modelName]; 11 | }); 12 | 13 | const collectionNames = Object.keys(connection.collections); 14 | collectionNames.forEach(collectionName => { 15 | delete connection.collections[collectionName]; 16 | }); 17 | }); 18 | 19 | const modelSchemaNames = Object.keys(mongoose.modelSchemas); 20 | modelSchemaNames.forEach(modelSchemaName => { 21 | delete mongoose.modelSchemas[modelSchemaName]; 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /packages/test/src/environment/mongodb.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const MongodbMemoryServer = require('mongodb-memory-server-global'); 3 | const NodeEnvironment = require('jest-environment-node'); 4 | 5 | class MongoDbEnvironment extends NodeEnvironment { 6 | constructor(config) { 7 | // console.error('\n# MongoDB Environment Constructor #\n'); 8 | super(config); 9 | this.mongod = new MongodbMemoryServer.default({ 10 | instance: { 11 | // settings here 12 | // dbName is null, so it's random 13 | // dbName: MONGO_DB_NAME, 14 | }, 15 | binary: { 16 | version: '4.0.5', 17 | }, 18 | // debug: true, 19 | autoStart: false, 20 | }); 21 | } 22 | 23 | async setup() { 24 | await super.setup(); 25 | // console.error('\n# MongoDB Environment Setup #\n'); 26 | await this.mongod.start(); 27 | this.global.__MONGO_URI__ = await this.mongod.getConnectionString(); 28 | this.global.__MONGO_DB_NAME__ = await this.mongod.getDbName(); 29 | this.global.__COUNTERS__ = { 30 | user: 0, 31 | company: 0, 32 | }; 33 | } 34 | 35 | async teardown() { 36 | await super.teardown(); 37 | // console.error('\n# MongoDB Environment Teardown #\n'); 38 | await this.mongod.stop(); 39 | this.mongod = null; 40 | this.global = {}; 41 | } 42 | 43 | runScript(script) { 44 | return super.runScript(script); 45 | } 46 | } 47 | 48 | module.exports = MongoDbEnvironment; 49 | -------------------------------------------------------------------------------- /packages/test/src/getObjectId.ts: -------------------------------------------------------------------------------- 1 | import { fromGlobalId } from 'graphql-relay'; 2 | import { Model, Types } from 'mongoose'; 3 | 4 | // returns an ObjectId given an param of unknown type 5 | export const getObjectId = (target: string | Model | Types.ObjectId): Types.ObjectId | null => { 6 | if (target instanceof Types.ObjectId) { 7 | return new Types.ObjectId(target.toString()); 8 | } 9 | 10 | if (typeof target === 'object') { 11 | return target && target._id ? new Types.ObjectId(target._id) : null; 12 | } 13 | 14 | if (Types.ObjectId.isValid(target)) { 15 | return new Types.ObjectId(target.toString()); 16 | } 17 | 18 | if (typeof target === 'string') { 19 | const result = fromGlobalId(target); 20 | 21 | if (result.type && result.id && Types.ObjectId.isValid(result.id)) { 22 | return new Types.ObjectId(result.id); 23 | } 24 | 25 | if (Types.ObjectId.isValid(target)) { 26 | return new Types.ObjectId(target); 27 | } 28 | 29 | return null; 30 | } 31 | 32 | return null; 33 | }; 34 | -------------------------------------------------------------------------------- /packages/test/src/index.ts: -------------------------------------------------------------------------------- 1 | export { getOrCreate } from './createResource/getOrCreate'; 2 | export { getCounter, restartCounters } from './counters'; 3 | export { clearDatabase, clearDbAndRestartCounters } from './clearDatabase'; 4 | export { connectMongoose } from './connectMongoose'; 5 | export { disconnectMongoose } from './disconnectMongoose'; 6 | export { getObjectId } from './getObjectId'; 7 | -------------------------------------------------------------------------------- /packages/test/src/setup.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | // eslint-disable-next-line 3 | console.log('\n# GLOBAL TEST SETUP #'); 4 | }; 5 | -------------------------------------------------------------------------------- /packages/test/src/setupTestFramework.js: -------------------------------------------------------------------------------- 1 | // this file is ran right after the test framework is setup for some test file. 2 | require('@babel/polyfill'); 3 | 4 | // jest.mock('graphql-redis-subscriptions'); 5 | 6 | // https://jestjs.io/docs/en/es6-class-mocks#simple-mock-using-module-factory-parameter 7 | 8 | require('jest-fetch-mock').enableMocks(); 9 | 10 | process.env.FEEDBACK_ENV = 'testing'; 11 | process.env.TZ = 'GMT'; 12 | -------------------------------------------------------------------------------- /packages/test/src/teardown.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | // eslint-disable-next-line 3 | console.log('# GLOBAL TEST TEARDOWN #'); 4 | }; 5 | -------------------------------------------------------------------------------- /packages/types/README.md: -------------------------------------------------------------------------------- 1 | # @fp/types 2 | 3 | Common types to be used on Fullstack Playground 4 | -------------------------------------------------------------------------------- /packages/types/babel.config.js: -------------------------------------------------------------------------------- 1 | const config = require('@fp/babel'); 2 | 3 | module.exports = config; 4 | -------------------------------------------------------------------------------- /packages/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fp/types", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "module": "src/index.ts", 6 | "scripts": { 7 | "build": "babel src --extensions \".es6,.js,.es,.jsx,.mjs,.ts,.tsx\" --ignore *.spec.js --out-dir dist --copy-files --source-maps --verbose" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/types/src/DeepPartial.ts: -------------------------------------------------------------------------------- 1 | export type DeepPartial = { 2 | [P in keyof T]?: T[P] extends (infer U)[] 3 | ? DeepPartial[] 4 | : T[P] extends readonly (infer U)[] 5 | ? readonly DeepPartial[] 6 | : DeepPartial; 7 | }; 8 | -------------------------------------------------------------------------------- /packages/types/src/index.ts: -------------------------------------------------------------------------------- 1 | export { DeepPartial } from './DeepPartial'; 2 | -------------------------------------------------------------------------------- /packages/types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | } 4 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Fullstack Playground 2 | The Fullstack Playground it is a place to be used as example for new technologies and used to build a new one too. 3 | 4 | ## What we have here 5 | - Rest API Server 6 | - GraphQL API Server 7 | - Test package 8 | - Babel Packge 9 | - Notification Package 10 | 11 | ## Getting Started 12 | First of all you need run a yarn command to install the `node_modules` 13 | 14 | ```terminal 15 | yarn 16 | ``` 17 | 18 | ### ENV 19 | Config your local .env for each package going to `.env.example` files 20 | 21 | ### MONGODB 22 | Create a local mongodb and this should be okay to run 23 | 24 | ### API 25 | ```terminal 26 | yarn api 27 | ``` 28 | 29 | ### SERVER 30 | ```terminal 31 | yarn server 32 | ``` 33 | 34 | ### package.json 35 | Go to package.json to see other command like build, prettier, jest, etc. 36 | 37 | ## Blog Posts 38 | The fullstack playground is used as a base to create blog posts, videos, etc. You can find here each one listed related to the package that it was used. 39 | 40 | ### Building Rest APIs with KoaJS [pt-BR] - NodeBR 41 | [![Building Rest APIs with koa-js pt-BR](http://img.youtube.com/vi/BwTFKripKL4/0.jpg)](http://www.youtube.com/watch?v=BwTFKripKL4 "Building Rest APIs with koa-js pt-BR") 42 | 43 | ### Talk with me 44 | If you want to understand more about the technologies used [here](https://calendly.com/daniloab/30min-daily-meeting), you can schedule a 30min talk here with me. -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "esnext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "moduleResolution": "node", 7 | "lib": [ /* Specify library files to be included in the compilation. */ 8 | "esnext", 9 | "dom", 10 | "dom.iterable" 11 | ], 12 | // "allowJs": true, /* Allow javascript files to be compiled. */ 13 | // "checkJs": true, /* Report errors in .js files. */ 14 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 15 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 16 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 17 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 18 | // "outFile": "./", /* Concatenate and emit output to single file. */ 19 | "outDir": "./distTs", /* Redirect output structure to the directory. */ 20 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 21 | // "composite": true, /* Enable project compilation */ 22 | // "removeComments": true, /* Do not emit comments to output. */ 23 | "noEmit": true, /* Do not emit outputs. */ 24 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 25 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 26 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 27 | 28 | /* Strict Type-Checking Options */ 29 | "strict": true, /* Enable all strict type-checking options. */ 30 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 31 | // "strictNullChecks": true, /* Enable strict null checks. */ 32 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | "paths": { /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | "@tecta/babel": ["packages/babel/src"] 48 | }, 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "resolveJsonModule": true, 54 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | "skipLibCheck": true 67 | } 68 | } -------------------------------------------------------------------------------- /webpack/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const nodeExternals = require('webpack-node-externals'); 4 | 5 | const cwd = process.cwd(); 6 | 7 | export const outputPath = path.join(cwd, '.webpack'); 8 | export const outputFilename = 'bundle.js'; 9 | 10 | export default { 11 | context: cwd, 12 | mode: 'development', 13 | devtool: false, 14 | resolve: { 15 | extensions: ['.ts', '.tsx', '.js', '.json', '.mjs'], 16 | }, 17 | output: { 18 | libraryTarget: 'commonjs2', 19 | path: outputPath, 20 | filename: outputFilename, 21 | }, 22 | target: 'node', 23 | externals: [ 24 | nodeExternals({ 25 | allowlist: [/@fp/], 26 | }), 27 | nodeExternals({ 28 | modulesDir: path.resolve(__dirname, '../node_modules'), 29 | allowlist: [/@fp/], 30 | }), 31 | ], 32 | module: { 33 | rules: [ 34 | { 35 | test: /\.mjs$/, 36 | type: 'javascript/auto', 37 | }, 38 | { 39 | test: /\.(js|jsx|ts|tsx)?$/, 40 | use: { 41 | loader: 'babel-loader?cacheDirectory', 42 | }, 43 | exclude: [/node_modules/, path.resolve(__dirname, '.serverless'), path.resolve(__dirname, '.webpack')], 44 | }, 45 | { 46 | test: /\.(pem|p12)?$/, 47 | use: { 48 | loader: 'raw-loader', 49 | }, 50 | }, 51 | ], 52 | }, 53 | plugins: [], 54 | node: { 55 | __dirname: false, 56 | __filename: false, 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /webpackx.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { ChildProcess, spawn } from 'child_process'; 3 | 4 | import webpack, { ProgressPlugin } from 'webpack'; 5 | 6 | import config, { outputPath, outputFilename } from './webpack/webpack.config'; 7 | 8 | const compilerRunPromise = (compiler) => 9 | new Promise((resolve, reject) => { 10 | compiler.run((err, stats) => { 11 | if (err) { 12 | return reject(err); 13 | } 14 | 15 | if (stats && stats.hasErrors()) { 16 | reject(err || stats.toString()); 17 | } 18 | 19 | resolve(stats); 20 | }); 21 | }); 22 | 23 | export function onExit(childProcess: ChildProcess): Promise { 24 | return new Promise((resolve, reject) => { 25 | childProcess.once('exit', (code: number) => { 26 | if (code === 0) { 27 | resolve(undefined); 28 | } else { 29 | reject(new Error(`Exit with error code: ${code}`)); 30 | } 31 | }); 32 | childProcess.once('error', (err: Error) => { 33 | reject(err); 34 | }); 35 | }); 36 | } 37 | 38 | const runProgram = async () => { 39 | const outputFile = path.join(outputPath, outputFilename); 40 | const execArgs = process.argv.slice(3); 41 | 42 | const childProcess = spawn(process.execPath, [outputFile, ...execArgs], { 43 | stdio: [process.stdin, process.stdout, process.stderr], 44 | }); 45 | 46 | await onExit(childProcess); 47 | }; 48 | 49 | (async () => { 50 | try { 51 | const wpConfig = { 52 | ...config, 53 | entry: path.join(__dirname, process.argv[2]), 54 | }; 55 | 56 | const compiler = webpack(wpConfig); 57 | 58 | compiler.hooks.beforeRun.tap('webpackProgress', () => { 59 | new ProgressPlugin(function (percentage, msg) { 60 | // eslint-disable-next-line 61 | console.log(percentage * 100 + '%', msg); 62 | }).apply(compiler); 63 | }); 64 | 65 | // eslint-disable-next-line 66 | const stats = await compilerRunPromise(compiler); 67 | 68 | // eslint-disable-next-line 69 | // console.log(stats.toString()); 70 | 71 | await runProgram(); 72 | } catch (err) { 73 | // eslint-disable-next-line 74 | console.log('err: ', err); 75 | process.exit(1); 76 | } 77 | process.exit(0); 78 | })(); 79 | --------------------------------------------------------------------------------