diff --git a/.secrets.baseline b/.secrets.baseline index 952441d55..9c8e8b88a 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -4,7 +4,7 @@ "files": "package-lock.json|^.secrets.baseline$", "lines": null }, - "generated_at": "2020-10-19T02:55:47Z", + "generated_at": "2021-04-09T07:48:36Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -55,10 +55,17 @@ } ], "results": { + "backend/src/test-utils/test-env.ts": [ + { + "hashed_secret": "c237a19676ad55d8904be354990dd54f92f9572c", + "is_verified": false, + "line_number": 4, + "type": "Base64 High Entropy String" + } + ], "frontend/.env-example": [ { "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_secret": false, "is_verified": false, "line_number": 5, "type": "Basic Auth Credentials" diff --git a/.travis.yml b/.travis.yml index 3bafa148e..3ae7b922d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,8 @@ node_js: services: - docker + - postgresql + - redis-server cache: npm diff --git a/amplify.yml b/amplify.yml index af433856e..480a42c55 100644 --- a/amplify.yml +++ b/amplify.yml @@ -29,7 +29,6 @@ frontend: build: commands: - npm run build - - CI=true npm run test - npm run upload-source-map artifacts: # IMPORTANT - Please verify your build output directory diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js index ba6a5fab8..35589cd58 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -1,13 +1,14 @@ module.exports = { root: false, parser: '@typescript-eslint/parser', - plugins: ['@typescript-eslint'], + plugins: ['@typescript-eslint', "jest"], extends: [ 'eslint:recommended', // Recommended ESLint rules 'plugin:@typescript-eslint/eslint-recommended', // Disables rules from `eslint:recommended` that are already covered by the TypeScript typechecker 'plugin:@typescript-eslint/recommended', // Recommended TypeScript rules 'prettier/@typescript-eslint', // Disables rules from `@typescript-eslint/recommended` that are covered by Prettier 'plugin:prettier/recommended', // Recommended Prettier rules + "plugin:jest/recommended" // Recommended Jest rules ], parserOptions: { sourceType: 'module', diff --git a/backend/Dockerfile b/backend/Dockerfile index f359c762b..0b4f17076 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -19,6 +19,7 @@ RUN npm ci COPY src ./src COPY tsconfig.json ./ +COPY tsconfig.build.json ./ RUN npm run build RUN npm prune --production diff --git a/backend/jest.config.js b/backend/jest.config.js index d6df66493..59a9518ef 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -1,14 +1,20 @@ module.exports = { roots: [''], - testMatch: ['**/__tests__/**/*.(spec|test).+(ts|tsx|js)'], + testMatch: ['**/tests/**/*.(spec|test).+(ts|tsx|js)'], + testPathIgnorePatterns: ['/build/', '/node_modules/'], moduleNameMapper: { '@core/(.*)': '/src/core/$1', '@sms/(.*)': '/src/sms/$1', '@email/(.*)': '/src/email/$1', + '@telegram/(.*)': '/src/telegram/$1', + '@test-utils/(.*)': '/src/test-utils/$1', }, transform: { '^.+\\.(ts|tsx)$': 'ts-jest', }, testEnvironment: 'node', - setupFilesAfterEnv: ['/__tests__/setup.js'], + globalSetup: '/src/test-utils/global-setup.ts', + globalTeardown: '/src/test-utils/global-teardown.ts', + setupFiles: ['/src/test-utils/test-env.ts'], + setupFilesAfterEnv: ['/src/test-utils/setup.ts'], } diff --git a/backend/package-lock.json b/backend/package-lock.json index e8e6d6a9b..bc6a78fdb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.22.4", + "version": "1.23.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1597,6 +1597,32 @@ "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" }, + "@nodelib/fs.scandir": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz", + "integrity": "sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.4", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.4.tgz", + "integrity": "sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.6.tgz", + "integrity": "sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.4", + "fastq": "^1.6.0" + } + }, "@otplib/core": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", @@ -1890,6 +1916,12 @@ "@types/node": "*" } }, + "@types/cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", + "dev": true + }, "@types/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.6.tgz", @@ -2141,9 +2173,9 @@ "dev": true }, "@types/papaparse": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.0.4.tgz", - "integrity": "sha512-jFv9NcRddMiW4+thmntwZ1AhvMDAX4+tAUDkWWbNcIzgqyjjkuSHOEUPoVh1/gqJTWfDOD1tvl+hSp88W3UtqA==", + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.2.5.tgz", + "integrity": "sha512-TlqGskBad6skAgx2ifQmkO/FwiwObuWltBvX2bDceQhXh9IyZ7jYCK7qwhjB67kxw+0LJDXXM4jN3lcGqm1g5w==", "dev": true, "requires": { "@types/node": "*" @@ -2204,6 +2236,25 @@ "integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==", "dev": true }, + "@types/superagent": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.10.tgz", + "integrity": "sha512-xAgkb2CMWUMCyVc/3+7iQfOEBE75NvuZeezvmixbUw3nmENf2tCnQkW5yQLTYqvXUQ+R6EXxdqKKbal2zM5V/g==", + "dev": true, + "requires": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, + "@types/supertest": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.10.tgz", + "integrity": "sha512-Xt8TbEyZTnD5Xulw95GLMOkmjGICrOQyJ2jqgkSjAUR3mm7pAIzSR0NFBaMcwlzVvlpCjNwbATcWWwjNiZiFrQ==", + "dev": true, + "requires": { + "@types/superagent": "*" + } + }, "@types/swagger-jsdoc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-3.0.2.tgz", @@ -2283,6 +2334,22 @@ "eslint-visitor-keys": "^1.1.0" } }, + "@typescript-eslint/scope-manager": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.12.0.tgz", + "integrity": "sha512-QVf9oCSVLte/8jvOsxmgBdOaoe2J0wtEmBr13Yz0rkBNkl5D8bfnf6G4Vhox9qqMIoG7QQoVwd2eG9DM/ge4Qg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "4.12.0", + "@typescript-eslint/visitor-keys": "4.12.0" + } + }, + "@typescript-eslint/types": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.12.0.tgz", + "integrity": "sha512-N2RhGeheVLGtyy+CxRmxdsniB7sMSCfsnbh8K/+RUIXYYq3Ub5+sukRCjVE80QerrUBvuEvs4fDhz5AW/pcL6g==", + "dev": true + }, "@typescript-eslint/typescript-estree": { "version": "2.25.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-2.25.0.tgz", @@ -2335,6 +2402,24 @@ } } }, + "@typescript-eslint/visitor-keys": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.12.0.tgz", + "integrity": "sha512-hVpsLARbDh4B9TKYz5cLbcdMIOAoBYgFPCSP9FFS/liSF+b33gVNq8JHY3QGhHNVz85hObvL7BEYLlgx553WCw==", + "dev": true, + "requires": { + "@typescript-eslint/types": "4.12.0", + "eslint-visitor-keys": "^2.0.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz", + "integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==", + "dev": true + } + } + }, "abab": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.3.tgz", @@ -2575,6 +2660,12 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true + }, "array-unique": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", @@ -3057,6 +3148,15 @@ "unset-value": "^1.0.0" } }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, "call-me-maybe": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", @@ -3498,6 +3598,12 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, + "cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==", + "dev": true + }, "copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", @@ -3727,9 +3833,9 @@ "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" }, "denque": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/denque/-/denque-1.4.1.tgz", - "integrity": "sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz", + "integrity": "sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==" }, "depd": { "version": "1.1.2", @@ -3758,6 +3864,15 @@ "integrity": "sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg==", "dev": true }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "requires": { + "path-type": "^4.0.0" + } + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -4033,6 +4148,86 @@ "get-stdin": "^6.0.0" } }, + "eslint-plugin-jest": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.1.3.tgz", + "integrity": "sha512-dNGGjzuEzCE3d5EPZQ/QGtmlMotqnYWD/QpCZ1UuZlrMAdhG5rldh0N0haCvhGnUkSeuORS5VNROwF9Hrgn3Lg==", + "dev": true, + "requires": { + "@typescript-eslint/experimental-utils": "^4.0.1" + }, + "dependencies": { + "@typescript-eslint/experimental-utils": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.12.0.tgz", + "integrity": "sha512-MpXZXUAvHt99c9ScXijx7i061o5HEjXltO+sbYfZAAHxv3XankQkPaNi5myy0Yh0Tyea3Hdq1pi7Vsh0GJb0fA==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.3", + "@typescript-eslint/scope-manager": "4.12.0", + "@typescript-eslint/types": "4.12.0", + "@typescript-eslint/typescript-estree": "4.12.0", + "eslint-scope": "^5.0.0", + "eslint-utils": "^2.0.0" + } + }, + "@typescript-eslint/typescript-estree": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.12.0.tgz", + "integrity": "sha512-gZkFcmmp/CnzqD2RKMich2/FjBTsYopjiwJCroxqHZIY11IIoN0l5lKqcgoAPKHt33H2mAkSfvzj8i44Jm7F4w==", + "dev": true, + "requires": { + "@typescript-eslint/types": "4.12.0", + "@typescript-eslint/visitor-keys": "4.12.0", + "debug": "^4.1.1", + "globby": "^11.0.1", + "is-glob": "^4.0.1", + "lodash": "^4.17.15", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + } + }, + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } + }, "eslint-plugin-prettier": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.3.tgz", @@ -4468,6 +4663,20 @@ "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", "dev": true }, + "fast-glob": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.4.tgz", + "integrity": "sha512-kr/Oo6PX51265qeuCYsyGypiO5uJFgBS0jksyG7FUeCyQzNwYnzrNIMR1NXfkZXsMYXYLRAHgISHBz8gQcxKHQ==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.0", + "merge2": "^1.3.0", + "micromatch": "^4.0.2", + "picomatch": "^2.2.1" + } + }, "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4489,6 +4698,15 @@ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.18.0.tgz", "integrity": "sha512-tRrwShhppv0K5GKEtuVs92W0VGDaVltZAwtHbpjNF+JOT7cjIFySBGTEOmdBslXYyWYaZwEX/g4Su8ZeKg0LKQ==" }, + "fastq": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.11.0.tgz", + "integrity": "sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g==", + "dev": true, + "requires": { + "reusify": "^1.0.4" + } + }, "fb-watchman": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz", @@ -4638,6 +4856,12 @@ "mime-types": "^2.1.12" } }, + "formidable": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz", + "integrity": "sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==", + "dev": true + }, "forwarded": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", @@ -4715,6 +4939,11 @@ } } }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, "functional-red-black-tree": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", @@ -4763,6 +4992,16 @@ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, "get-own-enumerable-property-symbols": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", @@ -4858,6 +5097,28 @@ "type-fest": "^0.8.1" } }, + "globby": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.2.tgz", + "integrity": "sha512-2ZThXDvvV8fYFRVIxnrMQBipZQDr7MxKAmQK1vujaj9/7eF0efG7BPUKJ7jP7G5SLF37xKDXvO4S/KKLj/Z0og==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.1.1", + "ignore": "^5.1.4", + "merge2": "^1.3.0", + "slash": "^3.0.0" + }, + "dependencies": { + "ignore": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", + "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==", + "dev": true + } + } + }, "graceful-fs": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", @@ -4886,12 +5147,25 @@ "har-schema": "^2.0.0" } }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", "dev": true }, + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==" + }, "has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -8033,6 +8307,12 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -8508,6 +8788,11 @@ } } }, + "object-inspect": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.2.tgz", + "integrity": "sha512-gz58rdPpadwztRrPjZE9DZLOABUpTGdcANUgOwBFO1C+HZZhePoP83M65WGDmbpwFYJSWqavbl4SgDn4k8RYTA==" + }, "object-visit": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", @@ -8976,10 +9261,10 @@ "postman-templating": { "version": "file:../modules/postman-templating", "requires": { - "@types/cheerio": "^0.22.24", + "@types/cheerio": "^0.22.28", "@types/lodash": "^4.14.168", "@types/mustache": "^4.1.1", - "@types/node": "^14.14.31", + "@types/node": "^14.14.42", "cheerio": "^1.0.0-rc.3", "lodash": "^4.17.21", "mustache": "^4.1.0", @@ -9872,9 +10157,9 @@ } }, "@types/cheerio": { - "version": "0.22.24", - "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.24.tgz", - "integrity": "sha512-iKXt/cwltGvN06Dd6zwQG1U35edPwId9lmcSeYfcxSNvvNg4vysnFB+iBQNjj06tSVV7MBj0GWMQ7dwb4Z+p8Q==", + "version": "0.22.28", + "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.28.tgz", + "integrity": "sha512-ehUMGSW5IeDxJjbru4awKYMlKGmo1wSSGUVqXtYwlgmUM8X1a0PZttEIm6yEY7vHsY/hh6iPnklF213G0UColw==", "requires": { "@types/node": "*" } @@ -9944,9 +10229,9 @@ "integrity": "sha512-Sm0NWeLhS2QL7NNGsXvO+Fgp7e3JLHCO6RS3RCnfjAnkw6Y1bsji/AGfISdQZDIR/AeOyzkrxRk9jBkl55zdJw==" }, "@types/node": { - "version": "14.14.31", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.31.tgz", - "integrity": "sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g==" + "version": "14.14.42", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.42.tgz", + "integrity": "sha512-88QoObqn9WYIUMRzOx92GmSHmU3JCyukC2ulEv8tFjUG9VeV2FQ/cA7VQ1gi+rB/+gBMVvzVFcTnz8RdMDVIWw==" }, "@types/normalize-package-data": { "version": "2.4.0", @@ -15495,20 +15780,20 @@ "dev": true }, "redis": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/redis/-/redis-3.0.2.tgz", - "integrity": "sha512-PNhLCrjU6vKVuMOyFu7oSP296mwBkcE6lrAjruBYG5LgdSqtRBoVQIylrMyVZD/lkF24RSNNatzvYag6HRBHjQ==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", + "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", "requires": { - "denque": "^1.4.1", - "redis-commands": "^1.5.0", + "denque": "^1.5.0", + "redis-commands": "^1.7.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0" } }, "redis-commands": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.5.0.tgz", - "integrity": "sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg==" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" }, "redis-errors": { "version": "1.2.0", @@ -15735,6 +16020,12 @@ "any-promise": "^1.3.0" } }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true + }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -15780,6 +16071,12 @@ "is-promise": "^2.1.0" } }, + "run-parallel": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.10.tgz", + "integrity": "sha512-zb/1OuZ6flOlH6tQyMPUrE3x3Ulxjlo9WIVXR4yVYi4H9UXQaeIsPbLn2R3O3vQCnDKkAl2qHiuocKKX4Tz/Sw==", + "dev": true + }, "rxjs": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz", @@ -16142,6 +16439,16 @@ "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", @@ -16643,6 +16950,125 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" }, + "superagent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-6.1.0.tgz", + "integrity": "sha512-OUDHEssirmplo3F+1HWKUrUjvnQuA+nZI6i/JJBdXb5eq9IyEQwPyPpqND+SSsxf6TygpBEkUjISVRN4/VOpeg==", + "dev": true, + "requires": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.2", + "debug": "^4.1.1", + "fast-safe-stringify": "^2.0.7", + "form-data": "^3.0.0", + "formidable": "^1.2.2", + "methods": "^1.1.2", + "mime": "^2.4.6", + "qs": "^6.9.4", + "readable-stream": "^3.6.0", + "semver": "^7.3.2" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "form-data": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz", + "integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "mime": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.7.tgz", + "integrity": "sha512-dhNd1uA2u397uQk3Nv5LM4lm93WYDUXFn3Fu291FJerns4jyTudqhIWe4W04YLy7Uk1tm1Ore04NpjRvQp/NPA==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "qs": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz", + "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==", + "dev": true + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + }, + "semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } + }, + "supertest": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.0.1.tgz", + "integrity": "sha512-8yDNdm+bbAN/jeDdXsRipbq9qMpVF7wRsbwLgsANHqdjPsCoecmlTuqEcLQMGpmojFBhxayZ0ckXmLXYq7e+0g==", + "dev": true, + "requires": { + "methods": "1.1.2", + "superagent": "6.1.0" + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -17081,9 +17507,9 @@ "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==" }, "ts-jest": { - "version": "25.4.0", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-25.4.0.tgz", - "integrity": "sha512-+0ZrksdaquxGUBwSdTIcdX7VXdwLIlSRsyjivVA9gcO+Cvr6ByqDhu/mi5+HCcb6cMkiQp5xZ8qRO7/eCqLeyw==", + "version": "25.5.1", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-25.5.1.tgz", + "integrity": "sha512-kHEUlZMK8fn8vkxDjwbHlxXRB9dHYpyzqKIGDNxbzs+Rz+ssNDSDNusEK8Fk/sDd4xE6iKoQLfFkFVaskmTJyw==", "dev": true, "requires": { "bs-logger": "0.x", @@ -17093,18 +17519,11 @@ "lodash.memoize": "4.x", "make-error": "1.x", "micromatch": "4.x", - "mkdirp": "1.x", - "resolve": "1.x", + "mkdirp": "0.x", "semver": "6.x", "yargs-parser": "18.x" }, "dependencies": { - "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true - }, "semver": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", @@ -17166,26 +17585,30 @@ "dev": true }, "twilio": { - "version": "3.55.1", - "resolved": "https://registry.npmjs.org/twilio/-/twilio-3.55.1.tgz", - "integrity": "sha512-7S8658CaMKArxRgYr+UvZQTxbp7n3WzurjuQdNX2GwVO34vZzmiuAuYvldaJrZ2mSBgsXUSNVcofQT6tGHGRSg==", + "version": "3.58.0", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-3.58.0.tgz", + "integrity": "sha512-ht+ixPbaJTeDEwxAEcF+m77vj/3m4wrbMpUZiLfhp5LTzuJDDDtzlplpyrVA9OtTLNtH0AdVpOgyo0W2eLtkZg==", "requires": { "axios": "^0.21.1", "dayjs": "^1.8.29", + "https-proxy-agent": "^5.0.0", "jsonwebtoken": "^8.5.1", "lodash": "^4.17.19", "q": "2.0.x", "qs": "^6.9.4", "rootpath": "^0.1.2", "scmp": "^2.1.0", - "url-parse": "^1.4.7", + "url-parse": "^1.5.0", "xmlbuilder": "^13.0.2" }, "dependencies": { "qs": { - "version": "6.9.6", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.6.tgz", - "integrity": "sha512-TIRk4aqYLNoJUbd+g2lEdz5kLWIuTMRagAXxl78Q0RiVjAOugHmeKNGdd3cwo/ktpf9aL9epCfFqWDEKysUlLQ==" + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz", + "integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==", + "requires": { + "side-channel": "^1.0.4" + } }, "xmlbuilder": { "version": "13.0.2", diff --git a/backend/package.json b/backend/package.json index f04b23bd5..29d22db23 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,17 +1,17 @@ { "name": "backend", - "version": "1.22.4", + "version": "1.23.0", "description": "Backend / API server for Postman", "main": "build/server.js", "scripts": { - "build": "rimraf build && tsc", + "build": "rimraf build && tsc -p tsconfig.build.json", "dev": "npm run postbuild && tsc-watch --onSuccess \"node ./build/server.js\"", "lint-no-fix": "tsc --noEmit && eslint --ext .js,.ts --cache .", "lint": "npm run lint-no-fix -- --fix", "postbuild": "npm run copy-assets", "start": "node build/server", "copy-assets": "copyfiles -u 1 src/assets/* src/**/*.sql build", - "test": "jest", + "test": "jest --maxWorkers=2", "test:watch": "jest --watch", "precommit": "lint-staged" }, @@ -46,7 +46,7 @@ "pg": "^8.5.1", "pg-connection-string": "^2.4.0", "postman-templating": "file:../modules/postman-templating", - "redis": "^3.0.2", + "redis": "^3.1.2", "reflect-metadata": "^0.1.13", "sequelize": "^5.22.4", "sequelize-typescript": "^1.1.0", @@ -57,7 +57,7 @@ "telegraf": "^3.38.0", "threads": "^1.4.0", "tiny-worker": "^2.3.0", - "twilio": "^3.55.1", + "twilio": "^3.58.0", "uuid": "^7.0.3", "winston": "^3.3.3", "winston-cloudwatch": "^2.5.2" @@ -80,9 +80,10 @@ "@types/morgan": "^1.9.0", "@types/nodemailer": "^6.4.0", "@types/nodemailer-direct-transport": "^1.0.31", - "@types/papaparse": "^5.0.4", + "@types/papaparse": "^5.2.5", "@types/qs": "^6.9.4", "@types/redis": "^2.8.17", + "@types/supertest": "^2.0.10", "@types/swagger-jsdoc": "^3.0.2", "@types/swagger-ui-express": "^4.1.2", "@types/uuid": "^7.0.2", @@ -92,12 +93,14 @@ "copyfiles": "^2.2.0", "eslint": "^6.8.0", "eslint-config-prettier": "^6.11.0", + "eslint-plugin-jest": "^24.1.3", "eslint-plugin-prettier": "^3.1.3", "jest": "^25.5.2", "lint-staged": "^10.2.6", "prettier": "^2.0.5", "rimraf": "^3.0.2", - "ts-jest": "^25.4.0", + "supertest": "^6.0.1", + "ts-jest": "^25.5.1", "tsc-watch": "^4.2.3", "typescript": "^3.8.3" }, diff --git a/backend/src/core/loaders/sequelize.loader.ts b/backend/src/core/loaders/sequelize.loader.ts index 4ed0c12da..a900913fb 100644 --- a/backend/src/core/loaders/sequelize.loader.ts +++ b/backend/src/core/loaders/sequelize.loader.ts @@ -2,35 +2,7 @@ import { Sequelize, SequelizeOptions } from 'sequelize-typescript' import { parse } from 'pg-connection-string' import config from '@core/config' -import { - Credential, - JobQueue, - Campaign, - Worker, - User, - UserFeature, - UserCredential, - UserDemo, - Statistic, - ProtectedMessage, - Unsubscriber, - Agency, -} from '@core/models' -import { - EmailMessage, - EmailTemplate, - EmailOp, - EmailBlacklist, - EmailFromAddress, -} from '@email/models' -import { SmsMessage, SmsTemplate, SmsOp } from '@sms/models' -import { - BotSubscriber, - TelegramMessage, - TelegramOp, - TelegramSubscriber, - TelegramTemplate, -} from '@telegram/models' +import { Credential, initializeModels } from '@core/models' import { loggerWithLabel } from '@core/logger' import { MutableConfig, generateRdsIamAuthToken } from '@core/utils/rds-iam' @@ -76,41 +48,7 @@ const sequelizeLoader = async (): Promise => { }, } as SequelizeOptions) - const coreModels = [ - Credential, - JobQueue, - Campaign, - Worker, - User, - UserFeature, - UserCredential, - UserDemo, - Statistic, - Unsubscriber, - Agency, - ] - const emailModels = [ - EmailMessage, - EmailTemplate, - EmailOp, - EmailBlacklist, - ProtectedMessage, - EmailFromAddress, - ] - const smsModels = [SmsMessage, SmsTemplate, SmsOp] - const telegramModels = [ - BotSubscriber, - TelegramOp, - TelegramMessage, - TelegramTemplate, - TelegramSubscriber, - ] - sequelize.addModels([ - ...coreModels, - ...emailModels, - ...smsModels, - ...telegramModels, - ]) + initializeModels(sequelize) try { await sequelize.sync() diff --git a/backend/src/core/models/index.ts b/backend/src/core/models/index.ts index 6b69773fb..bf3dc24e5 100644 --- a/backend/src/core/models/index.ts +++ b/backend/src/core/models/index.ts @@ -10,3 +10,4 @@ export * from './statistic' export * from './protected_message' export * from './unsubscriber' export * from './agency' +export * from './initialize-models' diff --git a/backend/src/core/models/initialize-models.ts b/backend/src/core/models/initialize-models.ts new file mode 100644 index 000000000..63e7fa1a0 --- /dev/null +++ b/backend/src/core/models/initialize-models.ts @@ -0,0 +1,70 @@ +import { Sequelize } from 'sequelize-typescript' +import { + Credential, + JobQueue, + Campaign, + Worker, + User, + UserFeature, + UserCredential, + UserDemo, + Statistic, + ProtectedMessage, + Unsubscriber, + Agency, +} from '@core/models' +import { + EmailMessage, + EmailTemplate, + EmailOp, + EmailBlacklist, + EmailFromAddress, +} from '@email/models' +import { SmsMessage, SmsTemplate, SmsOp } from '@sms/models' +import { + BotSubscriber, + TelegramMessage, + TelegramOp, + TelegramSubscriber, + TelegramTemplate, +} from '@telegram/models' + +export const initializeModels = (sequelize: Sequelize): void => { + const coreModels = [ + Credential, + JobQueue, + Campaign, + Worker, + User, + UserFeature, + UserCredential, + UserDemo, + Statistic, + Unsubscriber, + Agency, + ] + const emailModels = [ + EmailMessage, + EmailTemplate, + EmailOp, + EmailBlacklist, + ProtectedMessage, + EmailFromAddress, + ] + const smsModels = [SmsMessage, SmsTemplate, SmsOp] + const telegramModels = [ + BotSubscriber, + TelegramOp, + TelegramMessage, + TelegramTemplate, + TelegramSubscriber, + ] + sequelize.addModels([ + ...coreModels, + ...emailModels, + ...smsModels, + ...telegramModels, + ]) +} + +export default initializeModels diff --git a/backend/src/core/routes/tests/auth.routes.test.ts b/backend/src/core/routes/tests/auth.routes.test.ts new file mode 100644 index 000000000..5b83574a9 --- /dev/null +++ b/backend/src/core/routes/tests/auth.routes.test.ts @@ -0,0 +1,132 @@ +import request from 'supertest' +import { Sequelize } from 'sequelize-typescript' +import bcrypt from 'bcrypt' +import initialiseServer from '@test-utils/server' +import sequelizeLoader from '@test-utils/sequelize-loader' +import { MailService, RedisService } from '@core/services' +import { User } from '@core/models' + +const app = initialiseServer() +const appWithUserSession = initialiseServer(true) +let sequelize: Sequelize + +beforeAll(async () => { + sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') +}) + +afterEach(async () => { + await User.destroy({ where: {} }) +}) + +afterAll(async () => { + await sequelize.close() + RedisService.otpClient.quit() + RedisService.sessionClient.quit() +}) + +describe('POST /auth/otp', () => { + test('Invalid email format', async () => { + const res = await request(app) + .post('/auth/otp') + .send({ email: 'user!@open' }) + expect(res.status).toBe(400) + }) + + test('Non gov.sg and non-whitelisted email', async () => { + // There are no users in the db + const res = await request(app) + .post('/auth/otp') + .send({ email: 'user@agency.com.sg' }) + expect(res.status).toBe(401) + expect(res.body).toEqual({ message: 'User is not authorized' }) + }) + + test('OTP is generated and sent to user', async () => { + const res = await request(app) + .post('/auth/otp') + .send({ email: 'user@agency.gov.sg' }) + expect(res.status).toBe(200) + + expect(MailService.mailClient.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringMatching(/Your OTP is [0-9]{6}<\/b>/), + }) + ) + }) +}) + +describe('POST /auth/login', () => { + test('Invalid otp format provided', async () => { + const res = await request(app) + .post('/auth/login') + .send({ email: 'user@agency.gov.sg', otp: '123' }) + expect(res.status).toBe(400) + }) + + test('Invalid otp provided', async () => { + const res = await request(app) + .post('/auth/login') + .send({ email: 'user@agency.gov.sg', otp: '000000' }) + expect(res.status).toBe(401) + }) + + test('OTP is invalidated after retries are exceeded', async () => { + const email = 'user@agency.gov.sg' + RedisService.otpClient.set( + email, + JSON.stringify({ + retries: 1, + hash: await bcrypt.hash('123456', 10), + createdAt: 123, + }) + ) + const res = await request(app) + .post('/auth/login') + .send({ email, otp: '000000' }) + expect(res.status).toBe(401) + + // OTP should be deleted after exceeding retries + RedisService.otpClient.get(email, (_err, value) => { + expect(value).toBe(null) + }) + }) + + test('Valid otp provided', async () => { + const email = 'user@agency.gov.sg' + RedisService.otpClient.set( + email, + JSON.stringify({ + retries: 1, + hash: await bcrypt.hash('123456', 10), + createdAt: 123, + }) + ) + + const res = await request(app) + .post('/auth/login') + .send({ email, otp: '123456' }) + expect(res.status).toBe(200) + }) +}) + +describe('GET /auth/userinfo', () => { + test('No existing session', async () => { + const res = await request(app).get('/auth/userinfo') + expect(res.status).toBe(200) + expect(res.body).toEqual({}) + }) + + test('Existing session found', async () => { + await User.create({ id: 1, email: 'user@agency.gov.sg' }) + const res = await request(appWithUserSession).get('/auth/userinfo') + expect(res.status).toBe(200) + expect(res.body).toEqual({ id: 1, email: 'user@agency.gov.sg' }) + }) +}) + +describe('GET /auth/logout', () => { + test('Successfully logged out', async () => { + const res = await request(appWithUserSession).get('/auth/logout') + expect(res.status).toBe(200) + }) +}) diff --git a/backend/src/core/routes/tests/campaign.routes.test.ts b/backend/src/core/routes/tests/campaign.routes.test.ts new file mode 100644 index 000000000..ba2329e49 --- /dev/null +++ b/backend/src/core/routes/tests/campaign.routes.test.ts @@ -0,0 +1,203 @@ +import request from 'supertest' +import { Sequelize } from 'sequelize-typescript' +import initialiseServer from '@test-utils/server' +import { Campaign, User, UserDemo } from '@core/models' +import sequelizeLoader from '@test-utils/sequelize-loader' +import { RedisService } from '@core/services' +import { ChannelType } from '@core/constants' + +const app = initialiseServer(true) +let sequelize: Sequelize + +beforeAll(async () => { + sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') + await User.create({ id: 1, email: 'user@agency.gov.sg' }) +}) + +afterEach(async () => { + await Campaign.destroy({ where: {} }) +}) + +afterAll(async () => { + await User.destroy({ where: {} }) + await sequelize.close() + RedisService.otpClient.quit() + RedisService.sessionClient.quit() +}) + +describe('GET /campaigns', () => { + test('List campaigns with default limit and offset', async () => { + await Campaign.create({ + name: 'campaign-1', + userId: 1, + type: 'SMS', + valid: false, + protect: false, + }) + await Campaign.create({ + name: 'campaign-2', + userId: 1, + type: 'SMS', + valid: false, + protect: false, + }) + + const res = await request(app).get('/campaigns') + expect(res.status).toBe(200) + expect(res.body).toEqual({ + total_count: 2, + campaigns: expect.arrayContaining([ + expect.objectContaining({ id: expect.any(Number) }), + ]), + }) + }) + + test('List campaigns with defined limit and offset', async () => { + for (let i = 1; i <= 3; i++) { + await Campaign.create({ + name: `campaign-${i}`, + userId: 1, + type: 'SMS', + valid: false, + protect: false, + }) + } + + const res = await request(app) + .get('/campaigns') + .query({ limit: 1, offset: 2 }) + expect(res.status).toBe(200) + expect(res.body).toEqual({ + total_count: 3, + campaigns: expect.arrayContaining([ + expect.objectContaining({ name: 'campaign-1' }), + ]), + }) + }) +}) + +describe('POST /campaigns', () => { + test('Successfully create SMS campaign', async () => { + const res = await request(app).post('/campaigns').send({ + name: 'test', + type: ChannelType.SMS, + }) + expect(res.status).toBe(201) + expect(res.body).toEqual( + expect.objectContaining({ + name: 'test', + type: ChannelType.SMS, + protect: false, + }) + ) + }) + + test('Successfully create Email campaign', async () => { + const res = await request(app).post('/campaigns').send({ + name: 'test', + type: ChannelType.Email, + }) + expect(res.status).toBe(201) + expect(res.body).toEqual( + expect.objectContaining({ + name: 'test', + type: ChannelType.Email, + protect: false, + }) + ) + }) + + test('Successfully create Protected Email campaign', async () => { + const campaign = { + name: 'test', + type: ChannelType.Email, + protect: true, + } + const res = await request(app).post('/campaigns').send(campaign) + expect(res.status).toBe(201) + expect(res.body).toEqual(expect.objectContaining(campaign)) + }) + + test('Successfully create Telegram campaign', async () => { + const res = await request(app).post('/campaigns').send({ + name: 'test', + type: ChannelType.Telegram, + }) + expect(res.status).toBe(201) + expect(res.body).toEqual( + expect.objectContaining({ + name: 'test', + type: ChannelType.Telegram, + protect: false, + }) + ) + }) + + test('Successfully create demo SMS campaign', async () => { + const campaign = { + name: 'demo', + type: ChannelType.SMS, + demo_message_limit: 10, + } + const res = await request(app).post('/campaigns').send(campaign) + expect(res.status).toBe(201) + expect(res.body).toEqual( + expect.objectContaining({ + ...campaign, + demo_message_limit: 10, + }) + ) + + const demo = await UserDemo.findOne({ where: { userId: 1 } }) + expect(demo?.numDemosSms).toEqual(2) + }) + + test('Successfully create demo Telegram campaign', async () => { + const campaign = { + name: 'demo', + type: ChannelType.Telegram, + demo_message_limit: 10, + } + const res = await request(app).post('/campaigns').send(campaign) + expect(res.status).toBe(201) + expect(res.body).toEqual( + expect.objectContaining({ + ...campaign, + demo_message_limit: 10, + }) + ) + + const demo = await UserDemo.findOne({ where: { userId: 1 } }) + expect(demo?.numDemosTelegram).toEqual(2) + }) + + test('Unable to create demo Telegram campaign after user has no demos left', async () => { + const campaign = { + name: 'demo', + type: ChannelType.Telegram, + demo_message_limit: 10, + } + await UserDemo.update({ numDemosTelegram: 0 }, { where: { userId: 1 } }) + const res = await request(app).post('/campaigns').send(campaign) + expect(res.status).toBe(400) + }) + + test('Unable to create demo campaign for unsupported channel', async () => { + const campaign = { + name: 'demo', + type: ChannelType.Email, + demo_message_limit: 10, + } + const res = await request(app).post('/campaigns').send(campaign) + expect(res.status).toBe(400) + }) + + test('Unable to create protected campaign for unsupported channel', async () => { + const res = await request(app).post('/campaigns').send({ + name: 'test', + type: 'SMS', + protect: true, + }) + expect(res.status).toBe(403) + }) +}) diff --git a/backend/src/core/services/parse-csv.service.ts b/backend/src/core/services/parse-csv.service.ts index d7521534e..a47ea4b6a 100644 --- a/backend/src/core/services/parse-csv.service.ts +++ b/backend/src/core/services/parse-csv.service.ts @@ -51,10 +51,7 @@ const parseAndProcessCsv = async ( try { // `rows` can have no data, but just the meta if (!previewed && data.length > 0) { - if ( - meta.fields?.length > 0 && - !meta.fields?.includes('recipient') - ) { + if (meta.fields?.length && !meta.fields?.includes('recipient')) { throw new RecipientColumnMissing() } diff --git a/backend/__tests__/parse-csv.service.test.ts b/backend/src/core/services/tests/parse-csv.service.test.ts similarity index 100% rename from backend/__tests__/parse-csv.service.test.ts rename to backend/src/core/services/tests/parse-csv.service.test.ts diff --git a/backend/__tests__/phone-number.service.test.ts b/backend/src/core/services/tests/phone-number.service.test.ts similarity index 100% rename from backend/__tests__/phone-number.service.test.ts rename to backend/src/core/services/tests/phone-number.service.test.ts diff --git a/backend/src/sms/services/sms-callback.service.ts b/backend/src/sms/services/sms-callback.service.ts index 0500d0da2..70a4890d9 100644 --- a/backend/src/sms/services/sms-callback.service.ts +++ b/backend/src/sms/services/sms-callback.service.ts @@ -1,4 +1,5 @@ import { Request } from 'express' +import { Op } from 'sequelize' import bcrypt from 'bcrypt' import config from '@core/config' import { SmsMessage } from '@sms/models' @@ -55,6 +56,9 @@ const parseEvent = async (req: Request): Promise => { } ) } else { + // longer messages are delivered in multiple segments + // each segment has a separate delivery status + // Update the message as successful only if there does not exist previous failed status await SmsMessage.update( { receivedAt: new Date(), @@ -64,6 +68,7 @@ const parseEvent = async (req: Request): Promise => { where: { id: messageId, campaignId, + errorCode: { [Op.eq]: null }, }, } ) diff --git a/backend/src/telegram/utils/callback/handlers/contact.ts b/backend/src/telegram/utils/callback/handlers/contact.ts index 766cf845f..d09749693 100644 --- a/backend/src/telegram/utils/callback/handlers/contact.ts +++ b/backend/src/telegram/utils/callback/handlers/contact.ts @@ -1,5 +1,6 @@ import { TelegrafContext } from 'telegraf/typings/context' import { Message, ExtraReplyMessage } from 'telegraf/typings/telegram-types' +import { QueryTypes } from 'sequelize' import { loggerWithLabel } from '@core/logger' import { PostmanTelegramError } from '../PostmanTelegramError' @@ -22,40 +23,50 @@ const upsertTelegramSubscriber = async ( telegramId, action: 'upsertTelegramSubscriber', } + // Some Telegram clients send pre-prefixed phone numbers if (!phoneNumber.startsWith('+')) { phoneNumber = `+${phoneNumber}` } - /** - * Insert a telegram id and phone number, if that telegram id doesn't exist. - * Otherwise, if the new phone number does not exist, - * update the existing phone number associated with that telegram id with the new phone number, - * to maintain a 1-1 mapping. - */ - const result = await TelegramSubscriber?.sequelize?.query( - ` - INSERT INTO telegram_subscribers (phone_number, telegram_id, created_at, updated_at) - VALUES (:phoneNumber, :telegramId, clock_timestamp(), clock_timestamp()) - ON CONFLICT (telegram_id) DO UPDATE - SET phone_number = :phoneNumber, updated_at = clock_timestamp() - WHERE NOT telegram_subscribers.phone_number = :phoneNumber - `, - { - replacements: { - phoneNumber, - telegramId, - }, + const success = await TelegramSubscriber.sequelize?.transaction( + async (transaction) => { + /** + * Split into 2 scenarios: + * 1. a row with matching phone_number or telegram_id: update the row + * 2. no row matches: insert a new row + * + * Note: we have to use a raw query to update the phone_number. + * It is a primary key, and sequelize doesn't allow us to update + * primary keys in sequelize models. + */ + const result = await TelegramSubscriber.sequelize?.query( + ` + UPDATE telegram_subscribers + SET phone_number = :phoneNumber, telegram_id = :telegramId, updated_at = clock_timestamp() + WHERE phone_number = :phoneNumber OR telegram_id = :telegramId; + `, + { + transaction, + type: QueryTypes.UPDATE, + replacements: { phoneNumber, telegramId }, + } + ) + + const updatedCount = result?.[1] + if (!updatedCount) { + await TelegramSubscriber.create( + { telegramId, phoneNumber }, + { transaction } + ) + } + + return true } ) - const affectedRows = result ? (result[1] as number) : 0 - logger.info({ - message: 'Upserted Telegram subscribesr', - affectedRows, - ...logMeta, - }) - return affectedRows > 0 + logger.info({ message: 'Upserted Telegram subscriber', ...logMeta }) + return success ?? false } /** @@ -111,7 +122,7 @@ export const contactMessageHandler = (botId: string) => async ( } // Upsert and add subscriptions - await upsertTelegramSubscriber(phoneNumber, telegramId) + const upserted = await upsertTelegramSubscriber(phoneNumber, telegramId) const didAddBotSubscriber = await addBotSubscriber(botId, telegramId) // Respond @@ -121,8 +132,19 @@ export const contactMessageHandler = (botId: string) => async ( }, } - if (!didAddBotSubscriber) { - return ctx.reply('Your phone number has been updated.', replyOptions) + // Configure bot reply based on what actually happened + let reply + if (didAddBotSubscriber) { + reply = 'You are now subscribed.' + } else { + reply = 'You were already subscribed.' + } + + if (upserted) { + reply += ' Your phone number and Telegram ID have been updated.' + } else { + reply += ' An internal failure has occurred.' } - return ctx.reply('You are now subscribed.', replyOptions) + + return ctx.reply(reply, replyOptions) } diff --git a/backend/src/telegram/utils/callback/handlers/tests/contact.test.ts b/backend/src/telegram/utils/callback/handlers/tests/contact.test.ts new file mode 100644 index 000000000..487c515d8 --- /dev/null +++ b/backend/src/telegram/utils/callback/handlers/tests/contact.test.ts @@ -0,0 +1,153 @@ +import { Sequelize } from 'sequelize-typescript' +import { TelegrafContext } from 'telegraf/typings/context' +import sequelizeLoader from '@test-utils/sequelize-loader' +import { RedisService } from '@core/services' +import { BotSubscriber, TelegramSubscriber } from '@telegram/models' +import { contactMessageHandler } from '../contact' + +let sequelize: Sequelize +beforeAll(async () => { + sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') +}) + +afterAll(async () => { + await sequelize.close() + await new Promise((resolve) => { + RedisService.otpClient.quit(resolve) + }) + await new Promise((resolve) => { + RedisService.sessionClient.quit(resolve) + }) + await new Promise((resolve) => setImmediate(resolve)) +}) + +describe('contactMessageHandler', () => { + const botId = '123456789' + const phoneNumber = '+6591234567' + const telegramId = 123456789 + + const createMockContext = ( + phoneNumber: string, + userId: number + ): TelegrafContext => { + const ctx: any = { + reply: jest.fn(), + from: { + id: userId, + }, + message: { + contact: { + phone_number: phoneNumber, + user_id: userId, + }, + }, + } + return ctx + } + + const insertBotSubscription = async ( + phoneNumber: string, + telegramId: number, + botId: string + ): Promise => { + await TelegramSubscriber.create({ + phoneNumber, + telegramId, + }) + await BotSubscriber.create({ + botId, + telegramId, + }) + } + + afterEach(async () => { + await TelegramSubscriber.destroy({ where: {} }) + await BotSubscriber.destroy({ where: {} }) + }) + + test('Insertion of a new Telegram subscriber', async () => { + const ctx = createMockContext(phoneNumber, telegramId) + + await contactMessageHandler(botId)(ctx) + + const subscriber = await TelegramSubscriber.findOne({ + where: { phoneNumber }, + }) + expect(subscriber).not.toBeNull() + + const botSubscriber = await BotSubscriber.findOne({ + where: { botId, telegramId }, + }) + expect(botSubscriber).not.toBeNull() + + expect( + ctx.reply + ).toBeCalledWith( + 'You are now subscribed. Your phone number and Telegram ID have been updated.', + { reply_markup: { remove_keyboard: true } } + ) + }) + + test('Update of phone number for existing Telegram subscriber', async () => { + await insertBotSubscription(phoneNumber, telegramId, botId) + const newPhoneNumber = '+6581234567' + const ctx = createMockContext(newPhoneNumber, telegramId) + + await contactMessageHandler(botId)(ctx) + + const subscriber = await TelegramSubscriber.findOne({ + where: { phoneNumber: newPhoneNumber }, + }) + expect(subscriber?.telegramId).toBe(`${telegramId}`) + + const botSubscriber = await BotSubscriber.findOne({ + where: { botId, telegramId }, + }) + expect(botSubscriber).not.toBeNull() + + expect( + ctx.reply + ).toBeCalledWith( + 'You were already subscribed. Your phone number and Telegram ID have been updated.', + { reply_markup: { remove_keyboard: true } } + ) + }) + + test('Update of Telegram ID for existing Telegram subscriber', async () => { + await insertBotSubscription(phoneNumber, telegramId, botId) + const newTelegramId = 11111111 + const ctx = createMockContext(phoneNumber, newTelegramId) + + await contactMessageHandler(botId)(ctx) + + const subscriber = await TelegramSubscriber.findOne({ + where: { phoneNumber }, + }) + expect(subscriber?.telegramId).toBe(`${newTelegramId}`) + + const botSubscriber = await BotSubscriber.findOne({ + where: { botId, telegramId: newTelegramId }, + }) + expect(botSubscriber).not.toBeNull() + + expect( + ctx.reply + ).toBeCalledWith( + 'You were already subscribed. Your phone number and Telegram ID have been updated.', + { reply_markup: { remove_keyboard: true } } + ) + }) + + test('Skip update if contact details remain the same', async () => { + await insertBotSubscription(phoneNumber, telegramId, botId) + const ctx = createMockContext(phoneNumber, telegramId) + + await contactMessageHandler(botId)(ctx) + expect(ctx.reply).toBeCalledWith( + 'You were already subscribed. Your phone number and Telegram ID have been updated.', + { + reply_markup: { remove_keyboard: true }, + } + ) + }) +}) diff --git a/backend/src/test-utils/global-setup.ts b/backend/src/test-utils/global-setup.ts new file mode 100644 index 000000000..f80e22037 --- /dev/null +++ b/backend/src/test-utils/global-setup.ts @@ -0,0 +1,29 @@ +import { Sequelize, SequelizeOptions } from 'sequelize-typescript' +import config from '../core/config' + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace NodeJS { + interface Global { + sequelize: Sequelize + } + } +} +const DB_URI = 'postgres://localhost:5432/postgres' +const TEST_DB = 'postmangovsg_test' +// The number of workers should match the maxWorkers +// defined in npm test command +const JEST_WORKERS = 2 + +module.exports = async () => { + global.sequelize = new Sequelize(DB_URI, { + dialect: 'postgres', + logging: false, + pool: config.get('database.poolOptions'), + } as SequelizeOptions) + + for (let i = 1; i <= JEST_WORKERS; i++) { + await global.sequelize.query(`DROP DATABASE IF EXISTS ${TEST_DB}_${i}`) + await global.sequelize.query(`CREATE DATABASE ${TEST_DB}_${i}`) + } +} diff --git a/backend/src/test-utils/global-teardown.ts b/backend/src/test-utils/global-teardown.ts new file mode 100644 index 000000000..cf5586169 --- /dev/null +++ b/backend/src/test-utils/global-teardown.ts @@ -0,0 +1,3 @@ +module.exports = async function () { + await global.sequelize.close() +} diff --git a/backend/src/test-utils/sequelize-loader.ts b/backend/src/test-utils/sequelize-loader.ts new file mode 100644 index 000000000..392bbc0c9 --- /dev/null +++ b/backend/src/test-utils/sequelize-loader.ts @@ -0,0 +1,40 @@ +import { Sequelize, SequelizeOptions } from 'sequelize-typescript' +import config from '@core/config' +import { Credential, initializeModels } from '@core/models' + +import { DefaultCredentialName } from '@core/constants' +import { formatDefaultCredentialName } from '@core/utils' + +const DB_TEST_URI = config.get('database.databaseUri') + +const sequelizeLoader = async (dbName: string): Promise => { + const sequelize = new Sequelize(`${DB_TEST_URI}_${dbName}`, { + dialect: 'postgres', + logging: false, + pool: config.get('database.poolOptions'), + } as SequelizeOptions) + + initializeModels(sequelize) + + try { + await sequelize.sync() + console.log({ message: 'Test Database loaded.' }) + } catch (error) { + console.log(error.message) + console.error({ message: 'Unable to connect to test database', error }) + process.exit(1) + } + // Create the default credential names in the credentials table + // Each name should be accompanied by an entry in Secrets Manager + await Promise.all( + [ + DefaultCredentialName.Email, + formatDefaultCredentialName(DefaultCredentialName.SMS), + formatDefaultCredentialName(DefaultCredentialName.Telegram), + ].map((name) => Credential.upsert({ name })) + ) + + return sequelize +} + +export default sequelizeLoader diff --git a/backend/src/test-utils/server.ts b/backend/src/test-utils/server.ts new file mode 100644 index 000000000..d127e16a3 --- /dev/null +++ b/backend/src/test-utils/server.ts @@ -0,0 +1,29 @@ +import express, { Request, Response, NextFunction } from 'express' +import { errors as celebrateErrorMiddleware } from 'celebrate' +import bodyParser from 'body-parser' +import sessionLoader from '@core/loaders/session.loader' +import routes from '@core/routes' + +const initialiseServer = (session?: boolean): express.Application => { + const app: express.Application = express() + sessionLoader({ app }) + app.use(bodyParser.json()) + app.use(bodyParser.urlencoded({ extended: false })) + + app.use((req: Request, _res: Response, next: NextFunction): void => { + if (session && req.session) { + req.session.user = { + id: 1, + email: 'user@agency.gov.sg', + } + } + next() + }) + + app.use(routes) + app.use(celebrateErrorMiddleware()) + + return app +} + +export default initialiseServer diff --git a/backend/__tests__/setup.js b/backend/src/test-utils/setup.ts similarity index 70% rename from backend/__tests__/setup.js rename to backend/src/test-utils/setup.ts index d88a56f05..c8ff81811 100644 --- a/backend/__tests__/setup.js +++ b/backend/src/test-utils/setup.ts @@ -1,8 +1,12 @@ /* eslint-disable no-console */ global.console = { + ...global.console, log: jest.fn(), // console.log are ignored in tests error: console.error, warn: console.warn, info: console.info, debug: console.debug, } + +// Mock services +jest.mock('@core/services/mail-client.class') diff --git a/backend/src/test-utils/test-env.ts b/backend/src/test-utils/test-env.ts new file mode 100644 index 000000000..7d33c5b18 --- /dev/null +++ b/backend/src/test-utils/test-env.ts @@ -0,0 +1,6 @@ +process.env.REDIS_OTP_URI = 'redis://localhost:6379/3' +process.env.REDIS_SESSION_URI = 'redis://localhost:6379/4' +process.env.SENDGRID_PUBLIC_KEY = + 'MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEKWFCI/58CSJe4uz9WX7VZZBIoeb3c1UEJ+pe3HL0ywyGA6c3Bq92+1YVKv0HHxf5mjm+t47P672gcaYarlp2LA==' +process.env.SESSION_SECRET = 'SESSIONSECRET' +process.env.DB_URI = 'postgres://localhost:5432/postmangovsg_test' diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json new file mode 100644 index 000000000..b792dc00f --- /dev/null +++ b/backend/tsconfig.build.json @@ -0,0 +1,5 @@ +// Excludes test in builds +{ + "extends": "./tsconfig.json", + "exclude": ["tests/"] +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 32df37e0d..a16ed9ce9 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -1,6 +1,7 @@ { "include": [ "./src/**/*", + "./tests/**/*", ], "exclude": [ "node_modules" @@ -22,7 +23,7 @@ "sourceMap": true, /* Generates corresponding '.map' file. */ // "outFile": "./", /* Concatenate and emit output to single file. */ "outDir": "./build", /* Redirect output structure to the directory. */ - "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ // "composite": true, /* Enable project compilation */ // "incremental": true, /* Enable incremental compilation */ // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ @@ -56,6 +57,7 @@ "@sms/*": ["sms/*"], "@email/*": ["email/*"], "@telegram/*": ["telegram/*"], + "@test-utils/*": ["test-utils/*"], }, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ @@ -74,4 +76,4 @@ "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ }, -} \ No newline at end of file +} diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 2d03e4eb2..70d2d32f8 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -1,7 +1,7 @@ // eslint-disable-next-line no-undef module.exports = { parser: '@typescript-eslint/parser', - plugins: ['@typescript-eslint', 'react-hooks'], + plugins: ['@typescript-eslint', 'react-hooks', 'import'], extends: [ 'eslint:recommended', // Recommended ESLint rules 'plugin:@typescript-eslint/recommended', // Recommended TypeScript rules @@ -13,6 +13,9 @@ module.exports = { react: { version: 'detect', }, + 'import/resolver': { + typescript: {}, + }, }, parserOptions: { sourceType: 'module', @@ -26,6 +29,8 @@ module.exports = { ], rules: { 'react/prop-types': 'off', // No need proptypes since we're using TypeScript + 'react/jsx-uses-react': 'off', + 'react/react-in-jsx-scope': 'off', 'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks 'react-hooks/exhaustive-deps': 'warn', // Checks effect dependencies @@ -41,5 +46,15 @@ module.exports = { allow: ['warn', 'error'], }, ], + + 'import/order': [ + 'error', + { + 'newlines-between': 'always-and-inside-groups', + alphabetize: { + order: 'asc', + }, + }, + ], }, } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f8061464c..14ef1b6f7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "1.22.4", + "version": "1.23.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -4899,9 +4899,9 @@ "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==" }, "@types/papaparse": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.0.3.tgz", - "integrity": "sha512-SgWGWnBGxl6XgjKDM2eoDg163ZFQtH6m6C2aOuaAf1T2gUB3rjaiPDDARbY9WlacRgZqieRG9imAfJaJ+5ouDA==", + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.2.5.tgz", + "integrity": "sha512-TlqGskBad6skAgx2ifQmkO/FwiwObuWltBvX2bDceQhXh9IyZ7jYCK7qwhjB67kxw+0LJDXXM4jN3lcGqm1g5w==", "requires": { "@types/node": "*" } @@ -4928,22 +4928,23 @@ "integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==" }, "@types/react": { - "version": "16.9.29", - "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.29.tgz", - "integrity": "sha512-aE5sV9XVqKvIR8Lqa73hXvlqBzz5hBG0jtV9jZ1uuEWRmW8KN/mdQQmsYlPx6z/b2xa8zR3jtk7WoT+2/m4suA==", + "version": "16.14.5", + "resolved": "https://registry.npmjs.org/@types/react/-/react-16.14.5.tgz", + "integrity": "sha512-YRRv9DNZhaVTVRh9Wmmit7Y0UFhEVqXqCSw3uazRWMxa2x85hWQZ5BN24i7GXZbaclaLXEcodEeIHsjBA8eAMw==", "dev": true, "requires": { "@types/prop-types": "*", - "csstype": "^2.2.0" + "@types/scheduler": "*", + "csstype": "^3.0.2" } }, "@types/react-dom": { - "version": "16.9.5", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.5.tgz", - "integrity": "sha512-BX6RQ8s9D+2/gDhxrj8OW+YD4R+8hj7FEM/OJHGNR0KipE1h1mSsf39YeyC81qafkq+N3rU3h3RFbLSwE5VqUg==", + "version": "16.9.12", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.12.tgz", + "integrity": "sha512-i7NPZZpPte3jtVOoW+eLB7G/jsX5OM6GqQnH+lC0nq0rqwlK0x8WcMEvYDgFWqWhWMlTltTimzdMax6wYfZssA==", "dev": true, "requires": { - "@types/react": "*" + "@types/react": "^16" } }, "@types/react-draft-wysiwyg": { @@ -5003,6 +5004,12 @@ "@types/node": "*" } }, + "@types/scheduler": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.1.tgz", + "integrity": "sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA==", + "dev": true + }, "@types/set-cookie-parser": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.0.tgz", @@ -5853,9 +5860,9 @@ } }, "object-inspect": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz", - "integrity": "sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==" + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.2.tgz", + "integrity": "sha512-gz58rdPpadwztRrPjZE9DZLOABUpTGdcANUgOwBFO1C+HZZhePoP83M65WGDmbpwFYJSWqavbl4SgDn4k8RYTA==" }, "object.assign": { "version": "4.1.2", @@ -6954,6 +6961,27 @@ "isarray": "0.0.1" } }, + "react": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", + "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + } + }, + "react-dom": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", + "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.19.1" + } + }, "react-router": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-4.3.1.tgz", @@ -6980,6 +7008,15 @@ "react-router": "^4.3.1", "warning": "^4.0.1" } + }, + "scheduler": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", + "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } } } }, @@ -8326,9 +8363,9 @@ } }, "csstype": { - "version": "2.6.10", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.10.tgz", - "integrity": "sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.7.tgz", + "integrity": "sha512-KxnUB0ZMlnUWCsx2Z8MUsr6qV6ja1w9ArPErJaJaF8a5SOWoHLIszeCTKGRGRgtLgYrs1E8CHkNSP1VZTTPc9g==", "dev": true }, "currently-unhandled": { @@ -9438,6 +9475,31 @@ } } }, + "eslint-import-resolver-typescript": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-2.4.0.tgz", + "integrity": "sha512-useJKURidCcldRLCNKWemr1fFQL1SzB3G4a0li6lFGvlc5xGe1hY343bvG07cbpCzPuM/lK19FIJB3XGFSkplA==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "glob": "^7.1.6", + "is-glob": "^4.0.1", + "resolve": "^1.17.0", + "tsconfig-paths": "^3.9.0" + }, + "dependencies": { + "resolve": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", + "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "dev": true, + "requires": { + "is-core-module": "^2.2.0", + "path-parse": "^1.0.6" + } + } + } + }, "eslint-module-utils": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz", @@ -15351,9 +15413,9 @@ } }, "moment": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", - "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" }, "moo-color": { "version": "1.0.2", @@ -17745,10 +17807,10 @@ "postman-templating": { "version": "file:../modules/postman-templating", "requires": { - "@types/cheerio": "^0.22.24", + "@types/cheerio": "^0.22.28", "@types/lodash": "^4.14.168", "@types/mustache": "^4.1.1", - "@types/node": "^14.14.31", + "@types/node": "^14.14.42", "cheerio": "^1.0.0-rc.3", "lodash": "^4.17.21", "mustache": "^4.1.0", @@ -18641,9 +18703,9 @@ } }, "@types/cheerio": { - "version": "0.22.24", - "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.24.tgz", - "integrity": "sha512-iKXt/cwltGvN06Dd6zwQG1U35edPwId9lmcSeYfcxSNvvNg4vysnFB+iBQNjj06tSVV7MBj0GWMQ7dwb4Z+p8Q==", + "version": "0.22.28", + "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.28.tgz", + "integrity": "sha512-ehUMGSW5IeDxJjbru4awKYMlKGmo1wSSGUVqXtYwlgmUM8X1a0PZttEIm6yEY7vHsY/hh6iPnklF213G0UColw==", "requires": { "@types/node": "*" } @@ -18713,9 +18775,9 @@ "integrity": "sha512-Sm0NWeLhS2QL7NNGsXvO+Fgp7e3JLHCO6RS3RCnfjAnkw6Y1bsji/AGfISdQZDIR/AeOyzkrxRk9jBkl55zdJw==" }, "@types/node": { - "version": "14.14.31", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.31.tgz", - "integrity": "sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g==" + "version": "14.14.42", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.42.tgz", + "integrity": "sha512-88QoObqn9WYIUMRzOx92GmSHmU3JCyukC2ulEv8tFjUG9VeV2FQ/cA7VQ1gi+rB/+gBMVvzVFcTnz8RdMDVIWw==" }, "@types/normalize-package-data": { "version": "2.4.0", @@ -24264,9 +24326,9 @@ } }, "react": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react/-/react-16.13.1.tgz", - "integrity": "sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==", + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", + "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -24456,9 +24518,9 @@ } }, "react-dom": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz", - "integrity": "sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==", + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", + "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -24467,9 +24529,9 @@ } }, "react-draft-wysiwyg": { - "version": "1.14.5", - "resolved": "https://registry.npmjs.org/react-draft-wysiwyg/-/react-draft-wysiwyg-1.14.5.tgz", - "integrity": "sha512-utbJEs91757QXYoBwKRb/4kB3JdswLlj0heUiAeXs/OxZAUISJXxLMFLBIixRlIcUnNkwxOsMikRshDMtWIS3g==", + "version": "1.14.6", + "resolved": "https://registry.npmjs.org/react-draft-wysiwyg/-/react-draft-wysiwyg-1.14.6.tgz", + "integrity": "sha512-EMIteBVnlMAP3T97OfjlKchsHZ15mYWQwRw8QIInOtAvlZnnTydRjuHMCN0PuQ2dsXwMkJ7+MWc2aTL5RiXmFg==", "requires": { "classnames": "^2.2.6", "draftjs-utils": "^0.10.2", @@ -27744,9 +27806,9 @@ } }, "validator": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.0.0.tgz", - "integrity": "sha512-anYx5fURbgF04lQV18nEQWZ/3wHGnxiKdG4aL8J+jEDsm98n/sU/bey+tYk6tnGJzm7ioh5FoqrAiQ6m03IgaA==" + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.6.0.tgz", + "integrity": "sha512-gVgKbdbHgtxpRyR8K0O6oFZPhhB5tT1jeEHZR0Znr9Svg03U0+r9DXWMrnRAB+HtCStDQKlaIZm42tVsVjqtjg==" }, "value-equal": { "version": "1.0.1", diff --git a/frontend/package.json b/frontend/package.json index 9a319da84..0fd32abe0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "1.22.4", + "version": "1.23.0", "private": true, "scripts": { "start": "react-scripts start", @@ -20,7 +20,7 @@ "@lingui/react": "^3.5.1", "@sentry/browser": "^5.27.6", "@types/bcryptjs": "^2.4.2", - "@types/papaparse": "^5.0.3", + "@types/papaparse": "^5.2.5", "@types/validator": "^13.0.0", "axios": "^0.21.1", "boxicons": "^2.0.5", @@ -33,13 +33,13 @@ "html-to-draftjs": "^1.5.0", "lodash": "^4.17.21", "lottie-react": "^2.1.0", - "moment": "^2.24.0", + "moment": "^2.29.1", "papaparse": "^5.2.0", "postman-templating": "file:../modules/postman-templating", - "react": "^16.13.1", + "react": "^16.14.0", "react-app-polyfill": "^1.0.6", - "react-dom": "^16.13.1", - "react-draft-wysiwyg": "^1.14.5", + "react-dom": "^16.14.0", + "react-draft-wysiwyg": "^1.14.6", "react-ga": "^2.7.0", "react-moment": "^0.9.7", "react-paginate": "^6.3.2", @@ -51,7 +51,7 @@ "spark-md5": "^3.0.1", "typescript": "^4.2.3", "uuidv4": "^6.1.1", - "validator": "^13.0.0", + "validator": "^13.6.0", "webcrypto-shim": "^0.1.5" }, "devDependencies": { @@ -72,8 +72,8 @@ "@types/jest": "^26.0.21", "@types/lodash": "^4.14.165", "@types/node": "^12.12.34", - "@types/react": "^16.9.29", - "@types/react-dom": "^16.9.5", + "@types/react": "^16.14.5", + "@types/react-dom": "^16.9.12", "@types/react-draft-wysiwyg": "^1.13.0", "@types/react-paginate": "^6.2.1", "@types/react-router-dom": "^5.1.3", @@ -84,6 +84,8 @@ "babel-core": "^7.0.0-bridge.0", "eslint": "^7.22.0", "eslint-config-prettier": "^6.11.0", + "eslint-import-resolver-typescript": "^2.4.0", + "eslint-plugin-import": "^2.22.1", "eslint-plugin-prettier": "^3.1.3", "eslint-plugin-react": "^7.19.0", "jest-canvas-mock": "^2.3.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 01d91c8ee..9d9bc373a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,8 @@ -import React, { Suspense, lazy } from 'react' +// Components +import { Suspense, lazy } from 'react' + import { Route, Switch } from 'react-router-dom' -// Components import Landing from 'components/landing' import Login from 'components/login' import ProtectedPage from 'components/protected' diff --git a/frontend/src/classes/EmailCampaign.ts b/frontend/src/classes/EmailCampaign.ts index 1a7b9f0f7..980754220 100644 --- a/frontend/src/classes/EmailCampaign.ts +++ b/frontend/src/classes/EmailCampaign.ts @@ -1,6 +1,7 @@ -import { get } from 'lodash' import { i18n } from '@lingui/core' import { t } from '@lingui/macro' +import { get } from 'lodash' + import { Campaign, CampaignRecipient } from './Campaign' const emailErrors = { diff --git a/frontend/src/classes/SMSCampaign.ts b/frontend/src/classes/SMSCampaign.ts index e4f0b080e..aa82d2d60 100644 --- a/frontend/src/classes/SMSCampaign.ts +++ b/frontend/src/classes/SMSCampaign.ts @@ -1,4 +1,5 @@ import { t } from '@lingui/macro' + import { Campaign, CampaignRecipient } from './Campaign' export enum SMSProgress { diff --git a/frontend/src/classes/TelegramCampaign.ts b/frontend/src/classes/TelegramCampaign.ts index 823e3a314..f5a442843 100644 --- a/frontend/src/classes/TelegramCampaign.ts +++ b/frontend/src/classes/TelegramCampaign.ts @@ -1,4 +1,5 @@ import { t } from '@lingui/macro' + import { Campaign, CampaignRecipient } from './Campaign' export enum TelegramProgress { diff --git a/frontend/src/components/common/action-button/ActionButton.tsx b/frontend/src/components/common/action-button/ActionButton.tsx index 361fe37aa..7b5afadac 100644 --- a/frontend/src/components/common/action-button/ActionButton.tsx +++ b/frontend/src/components/common/action-button/ActionButton.tsx @@ -1,5 +1,7 @@ -import React, { useState } from 'react' import cx from 'classnames' +import { useState } from 'react' + +import type { MouseEvent as ReactMouseEvent } from 'react' import styles from './ActionButton.module.scss' @@ -8,7 +10,7 @@ const ActionButton = (props: any) => { const [toggleDropdown, setToggleDropdown] = useState(false) function handleToggleDropdown( - event: React.MouseEvent + event: ReactMouseEvent ) { event.stopPropagation() setToggleDropdown((toggleDropdown) => !toggleDropdown) diff --git a/frontend/src/components/common/body-wrapper/BodyWrapper.tsx b/frontend/src/components/common/body-wrapper/BodyWrapper.tsx index 255b433ba..5aa28affa 100644 --- a/frontend/src/components/common/body-wrapper/BodyWrapper.tsx +++ b/frontend/src/components/common/body-wrapper/BodyWrapper.tsx @@ -1,6 +1,7 @@ -import React from 'react' import cx from 'classnames' +import type { FunctionComponent } from 'react' + import styles from './BodyWrapper.module.scss' interface BodyWrapperProps { @@ -11,7 +12,7 @@ interface BodyWrapperProps { wrap?: boolean } -const BodyWrapper: React.FunctionComponent = ({ +const BodyWrapper: FunctionComponent = ({ wrap, children, }) =>
{children}
diff --git a/frontend/src/components/common/button-group/ButtonGroup.tsx b/frontend/src/components/common/button-group/ButtonGroup.tsx index 0534c53f2..177fb7fdb 100644 --- a/frontend/src/components/common/button-group/ButtonGroup.tsx +++ b/frontend/src/components/common/button-group/ButtonGroup.tsx @@ -1,8 +1,8 @@ -import React from 'react' +import type { ReactNode } from 'react' import styles from './ButtonGroup.module.scss' -const ButtonGroup = ({ children }: { children: React.ReactNode }) => { +const ButtonGroup = ({ children }: { children: ReactNode }) => { return
{children}
} diff --git a/frontend/src/components/common/checkbox/Checkbox.tsx b/frontend/src/components/common/checkbox/Checkbox.tsx index 058d887bf..d62d1d71a 100644 --- a/frontend/src/components/common/checkbox/Checkbox.tsx +++ b/frontend/src/components/common/checkbox/Checkbox.tsx @@ -1,6 +1,7 @@ -import React from 'react' -import type { Dispatch, SetStateAction } from 'react' import cx from 'classnames' +import type { Dispatch, SetStateAction } from 'react' + +import type { ReactNode } from 'react' import styles from './Checkbox.module.scss' @@ -13,7 +14,7 @@ const Checkbox = ({ checked: boolean onChange: Dispatch> className?: string - children?: React.ReactNode + children?: ReactNode }) => { return (
void - children: React.ReactNode + children: ReactNode }) => { const { numRecipients = 0, csvFilename, tempCsvFilename, csvError } = csvInfo diff --git a/frontend/src/components/common/detail-block/DetailBlock.tsx b/frontend/src/components/common/detail-block/DetailBlock.tsx index ae2e9c5a1..257925543 100644 --- a/frontend/src/components/common/detail-block/DetailBlock.tsx +++ b/frontend/src/components/common/detail-block/DetailBlock.tsx @@ -1,4 +1,3 @@ -import React from 'react' import cx from 'classnames' import styles from './DetailBlock.module.scss' diff --git a/frontend/src/components/common/dropdown/Dropdown.tsx b/frontend/src/components/common/dropdown/Dropdown.tsx index bef3c7cc2..fd4910e25 100644 --- a/frontend/src/components/common/dropdown/Dropdown.tsx +++ b/frontend/src/components/common/dropdown/Dropdown.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useRef } from 'react' import cx from 'classnames' +import { useState, useEffect, useRef } from 'react' import styles from './Dropdown.module.scss' diff --git a/frontend/src/components/common/email-preview-block/EmailPreviewBlock.tsx b/frontend/src/components/common/email-preview-block/EmailPreviewBlock.tsx index 102c91c7d..db400e710 100644 --- a/frontend/src/components/common/email-preview-block/EmailPreviewBlock.tsx +++ b/frontend/src/components/common/email-preview-block/EmailPreviewBlock.tsx @@ -1,9 +1,9 @@ import cx from 'classnames' -import React from 'react' import type { FC } from 'react' -import RichTextEditor from '../rich-text-editor' import DetailBlock from '../detail-block' +import RichTextEditor from '../rich-text-editor' + import styles from './EmailPreviewBlock.module.scss' interface EmailPreviewBlockProps { diff --git a/frontend/src/components/common/error-block/ErrorBlock.tsx b/frontend/src/components/common/error-block/ErrorBlock.tsx index 506c73d98..0967daf1e 100644 --- a/frontend/src/components/common/error-block/ErrorBlock.tsx +++ b/frontend/src/components/common/error-block/ErrorBlock.tsx @@ -1,8 +1,11 @@ -import React from 'react' import cx from 'classnames' -import styles from './ErrorBlock.module.scss' + +import type { ReactNode } from 'react' + import MessageBlock from '../message-block' +import styles from './ErrorBlock.module.scss' + const ErrorBlock = ({ className, children, @@ -12,7 +15,7 @@ const ErrorBlock = ({ ...otherProps }: { className?: string - children?: React.ReactNode + children?: ReactNode absolute?: boolean onClose?: () => void title?: string diff --git a/frontend/src/components/common/export-recipients/ExportRecipients.tsx b/frontend/src/components/common/export-recipients/ExportRecipients.tsx index 936b8d8f3..8c45dcfc0 100644 --- a/frontend/src/components/common/export-recipients/ExportRecipients.tsx +++ b/frontend/src/components/common/export-recipients/ExportRecipients.tsx @@ -1,15 +1,20 @@ -import React, { useState, useEffect } from 'react' +import { Trans } from '@lingui/macro' + import cx from 'classnames' + import download from 'downloadjs' +import moment from 'moment' +import { useState, useEffect } from 'react' + +import type { MouseEvent as ReactMouseEvent } from 'react' + +import styles from './ExportRecipients.module.scss' -import { Status } from 'classes/Campaign' import type { ChannelType } from 'classes/Campaign' +import { Status } from 'classes/Campaign' import { ActionButton, InfoBlock } from 'components/common' -import { GA_USER_EVENTS, sendUserEvent } from 'services/ga.service' import { exportCampaignStats } from 'services/campaign.service' -import styles from './ExportRecipients.module.scss' -import { Trans } from '@lingui/macro' -import moment from 'moment' +import { GA_USER_EVENTS, sendUserEvent } from 'services/ga.service' export enum CampaignExportStatus { Unavailable = 'Unavailable', @@ -72,7 +77,7 @@ const ExportRecipients = ({ }) async function exportRecipients( - event: React.MouseEvent + event: ReactMouseEvent ) { try { event.stopPropagation() diff --git a/frontend/src/components/common/file-input/FileInput.tsx b/frontend/src/components/common/file-input/FileInput.tsx index 559e92074..282c7d3eb 100644 --- a/frontend/src/components/common/file-input/FileInput.tsx +++ b/frontend/src/components/common/file-input/FileInput.tsx @@ -1,5 +1,3 @@ -import React from 'react' - import styles from './FileInput.module.scss' const FileInput = ({ diff --git a/frontend/src/components/common/info-banner/InfoBanner.tsx b/frontend/src/components/common/info-banner/InfoBanner.tsx index af20bf88e..05982bd9b 100644 --- a/frontend/src/components/common/info-banner/InfoBanner.tsx +++ b/frontend/src/components/common/info-banner/InfoBanner.tsx @@ -1,5 +1,3 @@ -import React from 'react' - import styles from './InfoBanner.module.scss' const InfoBanner = (props: any) => { diff --git a/frontend/src/components/common/info-block/InfoBlock.tsx b/frontend/src/components/common/info-block/InfoBlock.tsx index 680a8dcb5..edc8202cc 100644 --- a/frontend/src/components/common/info-block/InfoBlock.tsx +++ b/frontend/src/components/common/info-block/InfoBlock.tsx @@ -1,8 +1,11 @@ -import React from 'react' import cx from 'classnames' -import styles from './InfoBlock.module.scss' + +import type { ReactNode } from 'react' + import MessageBlock from '../message-block' +import styles from './InfoBlock.module.scss' + const InfoBlock = ({ className, children, @@ -12,7 +15,7 @@ const InfoBlock = ({ ...otherProps }: { className?: string - children?: React.ReactNode + children?: ReactNode absolute?: boolean onClose?: () => void title?: string diff --git a/frontend/src/components/common/label-with-external-link/LabelWithExternalLink.tsx b/frontend/src/components/common/label-with-external-link/LabelWithExternalLink.tsx index 24efe8731..add0429ca 100644 --- a/frontend/src/components/common/label-with-external-link/LabelWithExternalLink.tsx +++ b/frontend/src/components/common/label-with-external-link/LabelWithExternalLink.tsx @@ -1,6 +1,5 @@ -import React from 'react' - import { OutboundLink } from 'react-ga' + import styles from './LabelWithExternalLink.module.scss' const LabelWithExternalLink = ({ diff --git a/frontend/src/components/common/message-block/MessageBlock.tsx b/frontend/src/components/common/message-block/MessageBlock.tsx index ea8446fd8..f004d9cce 100644 --- a/frontend/src/components/common/message-block/MessageBlock.tsx +++ b/frontend/src/components/common/message-block/MessageBlock.tsx @@ -1,10 +1,11 @@ -import React from 'react' import cx from 'classnames' -import { CloseButton } from 'components/common' +import type { ReactNode } from 'react' import styles from './MessageBlock.module.scss' +import { CloseButton } from 'components/common' + const MessageBlock = ({ className, icon, @@ -17,7 +18,7 @@ const MessageBlock = ({ className?: string title?: string icon?: string - children?: React.ReactNode + children?: ReactNode absolute?: boolean onClose?: () => void role?: string diff --git a/frontend/src/components/common/modal/Modal.tsx b/frontend/src/components/common/modal/Modal.tsx index acfe19e6e..8479de382 100644 --- a/frontend/src/components/common/modal/Modal.tsx +++ b/frontend/src/components/common/modal/Modal.tsx @@ -1,16 +1,18 @@ import cx from 'classnames' -import React from 'react' -import { CloseButton } from 'components/common' +import type { ReactNode } from 'react' + import styles from './Modal.module.scss' +import { CloseButton } from 'components/common' + const Modal = ({ onClose, children, modalTitle, }: { onClose: any - children: React.ReactNode + children: ReactNode modalTitle?: string }) => { const modalBackgroundId = 'modal-background' diff --git a/frontend/src/components/common/nav-bar/NavBar.tsx b/frontend/src/components/common/nav-bar/NavBar.tsx index a4b398681..31a1a8f34 100644 --- a/frontend/src/components/common/nav-bar/NavBar.tsx +++ b/frontend/src/components/common/nav-bar/NavBar.tsx @@ -1,19 +1,23 @@ -import React, { useState, useContext } from 'react' -import { NavLink, useLocation } from 'react-router-dom' +import { i18n } from '@lingui/core' + import cx from 'classnames' + +import { useState, useContext } from 'react' + import { OutboundLink } from 'react-ga' -import { ModalContext } from 'contexts/modal.context' -import { TextButton } from 'components/common' -import CreateModal from 'components/dashboard/create/create-modal' -import { logout } from 'services/auth.service' -import { AuthContext } from 'contexts/auth.context' +import { NavLink, useLocation } from 'react-router-dom' -import AppLogo from 'assets/img/brand/app-logo-reverse.svg' import styles from './NavBar.module.scss' -import { i18n } from '@lingui/core' +import AppLogo from 'assets/img/brand/app-logo-reverse.svg' +import { TextButton } from 'components/common' +import CreateModal from 'components/dashboard/create/create-modal' import { LINKS } from 'config' +import { AuthContext } from 'contexts/auth.context' +import { ModalContext } from 'contexts/modal.context' + +import { logout } from 'services/auth.service' const NavBar = () => { const { setAuthenticated, email } = useContext(AuthContext) diff --git a/frontend/src/components/common/next-button/NextButton.tsx b/frontend/src/components/common/next-button/NextButton.tsx index 706b849c9..92d60b033 100644 --- a/frontend/src/components/common/next-button/NextButton.tsx +++ b/frontend/src/components/common/next-button/NextButton.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import type { ReactElement } from 'react' import { PrimaryButton } from 'components/common' @@ -9,7 +9,7 @@ const NextButton = ({ }: { onClick: (...args: any[]) => void | Promise disabled?: boolean - loadingPlaceholder?: string | React.ReactElement + loadingPlaceholder?: string | ReactElement }) => { return (
diff --git a/frontend/src/components/common/pagination/Pagination.tsx b/frontend/src/components/common/pagination/Pagination.tsx index a26d001f6..d7d51bb61 100644 --- a/frontend/src/components/common/pagination/Pagination.tsx +++ b/frontend/src/components/common/pagination/Pagination.tsx @@ -1,4 +1,3 @@ -import React from 'react' import ReactPaginate from 'react-paginate' import styles from './Pagination.module.scss' diff --git a/frontend/src/components/common/preview-block/PreviewBlock.tsx b/frontend/src/components/common/preview-block/PreviewBlock.tsx index 1c1161411..f358bbfe0 100644 --- a/frontend/src/components/common/preview-block/PreviewBlock.tsx +++ b/frontend/src/components/common/preview-block/PreviewBlock.tsx @@ -1,8 +1,10 @@ -import React from 'react' import cx from 'classnames' import escapeHTML from 'escape-html' +import type { FunctionComponent } from 'react' + import DetailBlock from '../detail-block' + import styles from './PreviewBlock.module.scss' interface PreviewBlockProps { @@ -13,7 +15,7 @@ interface PreviewBlockProps { className?: string } -const PreviewBlock: React.FunctionComponent = ({ +const PreviewBlock: FunctionComponent = ({ body, subject, replyTo, diff --git a/frontend/src/components/common/primary-button/PrimaryButton.tsx b/frontend/src/components/common/primary-button/PrimaryButton.tsx index 875490ab2..447c4a574 100644 --- a/frontend/src/components/common/primary-button/PrimaryButton.tsx +++ b/frontend/src/components/common/primary-button/PrimaryButton.tsx @@ -1,17 +1,24 @@ -import React, { useState, useMemo } from 'react' import cx from 'classnames' -import useIsMounted from 'components/custom-hooks/use-is-mounted' +import { useState, useMemo } from 'react' + +import type { + ButtonHTMLAttributes, + ReactElement, + FunctionComponent, +} from 'react' + import styles from './PrimaryButton.module.scss' -interface PrimaryButtonProps - extends React.ButtonHTMLAttributes { +import useIsMounted from 'components/custom-hooks/use-is-mounted' + +interface PrimaryButtonProps extends ButtonHTMLAttributes { alignRight?: boolean onClick?: (...args: any[]) => void | Promise - loadingPlaceholder?: string | React.ReactElement + loadingPlaceholder?: string | ReactElement } -const PrimaryButton: React.FunctionComponent = ({ +const PrimaryButton: FunctionComponent = ({ alignRight, className, disabled, diff --git a/frontend/src/components/common/progress-bar/ProgressBar.tsx b/frontend/src/components/common/progress-bar/ProgressBar.tsx index 20fd77ae5..ec2eda874 100644 --- a/frontend/src/components/common/progress-bar/ProgressBar.tsx +++ b/frontend/src/components/common/progress-bar/ProgressBar.tsx @@ -1,4 +1,3 @@ -import React from 'react' import cx from 'classnames' import styles from './ProgressBar.module.scss' diff --git a/frontend/src/components/common/progress-details/ProgressDetails.tsx b/frontend/src/components/common/progress-details/ProgressDetails.tsx index 9203120da..482a557e3 100644 --- a/frontend/src/components/common/progress-details/ProgressDetails.tsx +++ b/frontend/src/components/common/progress-details/ProgressDetails.tsx @@ -1,8 +1,14 @@ -import React, { useContext } from 'react' -import Moment from 'react-moment' +import { i18n } from '@lingui/core' +import { Trans } from '@lingui/macro' + import cx from 'classnames' -import { CampaignContext } from 'contexts/campaign.context' +import { useContext } from 'react' +import { OutboundLink } from 'react-ga' +import Moment from 'react-moment' + +import styles from './ProgressDetails.module.scss' + import { CampaignStats, Status } from 'classes/Campaign' import { ProgressBar, @@ -10,11 +16,8 @@ import { ExportRecipients, InfoBlock, } from 'components/common' -import styles from './ProgressDetails.module.scss' -import { OutboundLink } from 'react-ga' import { LINKS } from 'config' -import { i18n } from '@lingui/core' -import { Trans } from '@lingui/macro' +import { CampaignContext } from 'contexts/campaign.context' const ProgressDetails = ({ stats, diff --git a/frontend/src/components/common/progress-pane/ProgressPane.tsx b/frontend/src/components/common/progress-pane/ProgressPane.tsx index 56fc2c4bf..4bae9dc13 100644 --- a/frontend/src/components/common/progress-pane/ProgressPane.tsx +++ b/frontend/src/components/common/progress-pane/ProgressPane.tsx @@ -1,5 +1,5 @@ -import React from 'react' import cx from 'classnames' + import styles from './ProgressPane.module.scss' const ProgressItem = ({ diff --git a/frontend/src/components/common/protected-preview/ProtectedPreview.tsx b/frontend/src/components/common/protected-preview/ProtectedPreview.tsx index b315d9258..fd6ecb490 100644 --- a/frontend/src/components/common/protected-preview/ProtectedPreview.tsx +++ b/frontend/src/components/common/protected-preview/ProtectedPreview.tsx @@ -1,7 +1,7 @@ -import React from 'react' import cx from 'classnames' import styles from './ProtectedPreview.module.scss' + import appLogo from 'assets/img/brand/app-logo-grey.svg' /** diff --git a/frontend/src/components/common/rich-text-editor/RichTextEditor.tsx b/frontend/src/components/common/rich-text-editor/RichTextEditor.tsx index eb2c4caac..7c487e176 100644 --- a/frontend/src/components/common/rich-text-editor/RichTextEditor.tsx +++ b/frontend/src/components/common/rich-text-editor/RichTextEditor.tsx @@ -1,6 +1,4 @@ import cx from 'classnames' -import React, { useContext, useEffect, useState } from 'react' -import Immutable from 'immutable' import { EditorState, ContentBlock, @@ -12,8 +10,17 @@ import { SelectionState, KeyBindingUtil, } from 'draft-js' +import Immutable from 'immutable' +import { createContext, useContext, useEffect, useState } from 'react' +import type { + KeyboardEvent as ReactKeyboardEvent, + Dispatch, + SetStateAction, +} from 'react' import { Editor } from 'react-draft-wysiwyg' +import styles from './RichTextEditor.module.scss' +import { ImageBlock, TableWrapper } from './blocks' import { LinkControl, ImageControl, @@ -30,11 +37,9 @@ import { PreviewLinkDecorator, } from './decorators' import { Converter } from './utils' -import { ImageBlock, TableWrapper } from './blocks' import 'draft-js/dist/Draft.css' import 'react-draft-wysiwyg/dist/react-draft-wysiwyg.css' -import styles from './RichTextEditor.module.scss' const ExtendedEditor = (props: any) => @@ -116,9 +121,9 @@ const TOOLBAR_CUSTOM_BUTTONS = [] const defaultValue = { editorState: EditorState.createEmpty(), - setEditorState: {} as React.Dispatch>, + setEditorState: {} as Dispatch>, } -export const EditorContext = React.createContext(defaultValue) +export const EditorContext = createContext(defaultValue) const RichTextEditor = ({ onChange, @@ -235,7 +240,7 @@ const RichTextEditor = ({ } function handleReturn( - e: React.KeyboardEvent, + e: ReactKeyboardEvent, state: EditorState ): 'handled' | 'not-handled' { const selection = state.getSelection() diff --git a/frontend/src/components/common/rich-text-editor/blocks/ImageBlock.tsx b/frontend/src/components/common/rich-text-editor/blocks/ImageBlock.tsx index 0a5c55ff3..d693a85ae 100644 --- a/frontend/src/components/common/rich-text-editor/blocks/ImageBlock.tsx +++ b/frontend/src/components/common/rich-text-editor/blocks/ImageBlock.tsx @@ -1,4 +1,3 @@ -import React, { useRef, useState, useContext } from 'react' import { ContentBlock, ContentState, @@ -6,6 +5,7 @@ import { SelectionState, Modifier, } from 'draft-js' +import { useRef, useState, useContext } from 'react' import { EditorContext } from '../RichTextEditor' diff --git a/frontend/src/components/common/rich-text-editor/blocks/TableWrapper.tsx b/frontend/src/components/common/rich-text-editor/blocks/TableWrapper.tsx index e0cc9f4f8..618a75de5 100644 --- a/frontend/src/components/common/rich-text-editor/blocks/TableWrapper.tsx +++ b/frontend/src/components/common/rich-text-editor/blocks/TableWrapper.tsx @@ -1,4 +1,5 @@ -import React, { useContext } from 'react' +import { useContext } from 'react' + import { EditorContext } from '../RichTextEditor' export const TableWrapper = (props: any) => { diff --git a/frontend/src/components/common/rich-text-editor/controls/BlockTypeControl.tsx b/frontend/src/components/common/rich-text-editor/controls/BlockTypeControl.tsx index a4bcb0648..93c09dc40 100644 --- a/frontend/src/components/common/rich-text-editor/controls/BlockTypeControl.tsx +++ b/frontend/src/components/common/rich-text-editor/controls/BlockTypeControl.tsx @@ -1,5 +1,5 @@ -import React, { useContext } from 'react' import cx from 'classnames' +import { useContext } from 'react' import { EditorContext } from '../RichTextEditor' diff --git a/frontend/src/components/common/rich-text-editor/controls/FontColorControl.tsx b/frontend/src/components/common/rich-text-editor/controls/FontColorControl.tsx index 8bf898377..8427801d5 100644 --- a/frontend/src/components/common/rich-text-editor/controls/FontColorControl.tsx +++ b/frontend/src/components/common/rich-text-editor/controls/FontColorControl.tsx @@ -1,4 +1,3 @@ -import React from 'react' import cx from 'classnames' import styles from '../RichTextEditor.module.scss' diff --git a/frontend/src/components/common/rich-text-editor/controls/ImageControl.tsx b/frontend/src/components/common/rich-text-editor/controls/ImageControl.tsx index 7fd350e5a..10380b28a 100644 --- a/frontend/src/components/common/rich-text-editor/controls/ImageControl.tsx +++ b/frontend/src/components/common/rich-text-editor/controls/ImageControl.tsx @@ -1,8 +1,10 @@ import cx from 'classnames' -import React, { useContext, useState } from 'react' import { AtomicBlockUtils } from 'draft-js' -import { EditorContext } from '../RichTextEditor' +import { useContext, useState } from 'react' + +import type { FormEvent, MouseEvent as ReactMouseEvent } from 'react' +import { EditorContext } from '../RichTextEditor' import styles from '../RichTextEditor.module.scss' const VARIABLE_REGEX = new RegExp(/^{{\s*?\w+\s*?}}$/) @@ -24,11 +26,11 @@ const ImageForm = ({ const [imgSrc, setImgSrc] = useState('') const [link, setLink] = useState('') - function stopPropagation(e: React.MouseEvent) { + function stopPropagation(e: ReactMouseEvent) { e.stopPropagation() } - function handleSubmit(e: React.FormEvent) { + function handleSubmit(e: FormEvent) { e.preventDefault() onChange(imgSrc, 'auto', '100%', link) } diff --git a/frontend/src/components/common/rich-text-editor/controls/InlineControl.tsx b/frontend/src/components/common/rich-text-editor/controls/InlineControl.tsx index 8890c1dd9..d4521808a 100644 --- a/frontend/src/components/common/rich-text-editor/controls/InlineControl.tsx +++ b/frontend/src/components/common/rich-text-editor/controls/InlineControl.tsx @@ -1,4 +1,3 @@ -import React from 'react' import cx from 'classnames' interface InlineControlProps { diff --git a/frontend/src/components/common/rich-text-editor/controls/LinkControl.tsx b/frontend/src/components/common/rich-text-editor/controls/LinkControl.tsx index a12616bde..1b4f660a0 100644 --- a/frontend/src/components/common/rich-text-editor/controls/LinkControl.tsx +++ b/frontend/src/components/common/rich-text-editor/controls/LinkControl.tsx @@ -1,4 +1,7 @@ -import React, { useState } from 'react' +import { useState } from 'react' + +import type { FormEvent, MouseEvent as ReactMouseEvent } from 'react' + import styles from '../RichTextEditor.module.scss' interface LinkControlProps { @@ -21,11 +24,11 @@ const LinkForm = ({ const [title, setTitle] = useState(link?.title || selectionText) const [url, setURL] = useState(link?.target || '') - function stopPropagation(e: React.MouseEvent) { + function stopPropagation(e: ReactMouseEvent) { e.stopPropagation() } - function handleSubmit(e: React.FormEvent) { + function handleSubmit(e: FormEvent) { e.preventDefault() onSubmit({ title, url }) } diff --git a/frontend/src/components/common/rich-text-editor/controls/ListControl.tsx b/frontend/src/components/common/rich-text-editor/controls/ListControl.tsx index 49bb6f9da..9beaf2786 100644 --- a/frontend/src/components/common/rich-text-editor/controls/ListControl.tsx +++ b/frontend/src/components/common/rich-text-editor/controls/ListControl.tsx @@ -1,5 +1,5 @@ -import React, { useContext } from 'react' import cx from 'classnames' +import { useContext } from 'react' import { EditorContext } from '../RichTextEditor' diff --git a/frontend/src/components/common/rich-text-editor/controls/TableControl.tsx b/frontend/src/components/common/rich-text-editor/controls/TableControl.tsx index 0102033aa..34a797d02 100644 --- a/frontend/src/components/common/rich-text-editor/controls/TableControl.tsx +++ b/frontend/src/components/common/rich-text-editor/controls/TableControl.tsx @@ -1,5 +1,3 @@ -import React, { useState, useEffect } from 'react' -import { Map } from 'immutable' import cx from 'classnames' import { Modifier, @@ -8,6 +6,11 @@ import { ContentBlock, genKey, } from 'draft-js' +import { Map } from 'immutable' +import { useState, useEffect } from 'react' + +import type { MouseEvent as ReactMouseEvent } from 'react' + import styles from '../RichTextEditor.module.scss' const MIN_GRID_SIZE = 5 @@ -86,7 +89,7 @@ export const TableControl = ({ setShowPopover(false) } - function handleClick(e: React.MouseEvent) { + function handleClick(e: ReactMouseEvent) { e.stopPropagation() if (!isDisabled()) setShowPopover(() => !showPopover) } diff --git a/frontend/src/components/common/rich-text-editor/controls/TextAlignControl.tsx b/frontend/src/components/common/rich-text-editor/controls/TextAlignControl.tsx index ac904d468..a9a7f6dd1 100644 --- a/frontend/src/components/common/rich-text-editor/controls/TextAlignControl.tsx +++ b/frontend/src/components/common/rich-text-editor/controls/TextAlignControl.tsx @@ -1,5 +1,5 @@ -import React, { useContext } from 'react' import cx from 'classnames' +import { useContext } from 'react' import { EditorContext } from '../RichTextEditor' diff --git a/frontend/src/components/common/rich-text-editor/decorators/LinkDecorator.tsx b/frontend/src/components/common/rich-text-editor/decorators/LinkDecorator.tsx index 5d22ec952..960c7d7cf 100644 --- a/frontend/src/components/common/rich-text-editor/decorators/LinkDecorator.tsx +++ b/frontend/src/components/common/rich-text-editor/decorators/LinkDecorator.tsx @@ -1,5 +1,3 @@ -import React, { useContext, useRef, useState } from 'react' -import ReactDOM from 'react-dom' import { ContentBlock, ContentState, @@ -7,6 +5,9 @@ import { SelectionState, RichUtils, } from 'draft-js' +import { useContext, useRef, useState } from 'react' +import type { ReactChildren, MouseEvent as ReactMouseEvent } from 'react' +import ReactDOM from 'react-dom' import { EditorContext } from '../RichTextEditor' @@ -30,7 +31,7 @@ const linkStrategy = ( const LinkSpan = (props: { blockKey: string - children: React.ReactChildren + children: ReactChildren entityKey: string contentState: ContentState start: number @@ -53,7 +54,7 @@ const LinkSpan = (props: { hidePopover() } - function removeLink(e: React.MouseEvent) { + function removeLink(e: ReactMouseEvent) { e.preventDefault() e.stopPropagation() @@ -72,7 +73,7 @@ const LinkSpan = (props: { setPopoverStyle({}) } - function handleClick(event: React.MouseEvent) { + function handleClick(event: ReactMouseEvent) { if (!showPopover) { const linkElement: HTMLElement = event.currentTarget const dimensions = linkElement.getBoundingClientRect() @@ -127,7 +128,7 @@ const PreviewLinkSpan = ({ contentState, entityKey, }: { - children: React.ReactChildren + children: ReactChildren contentState: ContentState entityKey: string }) => { diff --git a/frontend/src/components/common/rich-text-editor/decorators/VariableDecorator.tsx b/frontend/src/components/common/rich-text-editor/decorators/VariableDecorator.tsx index 466fd0a15..755ad2a8c 100644 --- a/frontend/src/components/common/rich-text-editor/decorators/VariableDecorator.tsx +++ b/frontend/src/components/common/rich-text-editor/decorators/VariableDecorator.tsx @@ -1,6 +1,7 @@ -import React from 'react' import { ContentBlock, ContentState, DraftDecorator } from 'draft-js' +import type { Props } from 'react' + const HIGHLIGHT_REGEX = /{{\s*?\w+\s*?}}/g const variableStrategy = ( @@ -16,7 +17,7 @@ const variableStrategy = ( } } -const VariableSpan = (props: React.Props) => { +const VariableSpan = (props: Props) => { return {props.children} } diff --git a/frontend/src/components/common/sample-csv/SampleCsv.tsx b/frontend/src/components/common/sample-csv/SampleCsv.tsx index 28fef95b8..83a5b92b8 100644 --- a/frontend/src/components/common/sample-csv/SampleCsv.tsx +++ b/frontend/src/components/common/sample-csv/SampleCsv.tsx @@ -1,15 +1,15 @@ -import React from 'react' -import { without, times, constant } from 'lodash' import download from 'downloadjs' +import { without, times, constant } from 'lodash' + +import TextButton from '../text-button' + +import styles from './SampleCsv.module.scss' +import { GA_USER_EVENTS, sendUserEvent } from 'services/ga.service' import { PROTECTED_CSV_HEADERS, extractParams, } from 'services/validate-csv.service' -import { GA_USER_EVENTS, sendUserEvent } from 'services/ga.service' -import TextButton from '../text-button' - -import styles from './SampleCsv.module.scss' const SampleCsv = ({ defaultRecipient, diff --git a/frontend/src/components/common/send-rate/SendRate.tsx b/frontend/src/components/common/send-rate/SendRate.tsx index a2aa59853..63d3f0eb4 100644 --- a/frontend/src/components/common/send-rate/SendRate.tsx +++ b/frontend/src/components/common/send-rate/SendRate.tsx @@ -1,11 +1,15 @@ -import React, { Dispatch, SetStateAction, useState } from 'react' -import { OutboundLink } from 'react-ga' +import { i18n } from '@lingui/core' + import cx from 'classnames' -import { LINKS } from 'config' -import { TextInput } from 'components/common' + +import { Dispatch, SetStateAction, useState } from 'react' +import { OutboundLink } from 'react-ga' + import styles from './SendRate.module.scss' + import { ChannelType } from 'classes' -import { i18n } from '@lingui/core' +import { TextInput } from 'components/common' +import { LINKS } from 'config' const SendRate = ({ sendRate, diff --git a/frontend/src/components/common/side-nav/SideNav.tsx b/frontend/src/components/common/side-nav/SideNav.tsx index 359e66116..e87ffc379 100644 --- a/frontend/src/components/common/side-nav/SideNav.tsx +++ b/frontend/src/components/common/side-nav/SideNav.tsx @@ -1,6 +1,7 @@ -import React from 'react' -import { useHistory, NavLink } from 'react-router-dom' import cx from 'classnames' + +import { useHistory, NavLink } from 'react-router-dom' + import styles from './SideNav.module.scss' import { Dropdown } from 'components/common' diff --git a/frontend/src/components/common/step-header/StepHeader.tsx b/frontend/src/components/common/step-header/StepHeader.tsx index 632f2fa71..46063a998 100644 --- a/frontend/src/components/common/step-header/StepHeader.tsx +++ b/frontend/src/components/common/step-header/StepHeader.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import { ReactNode } from 'react' import styles from './StepHeader.module.scss' @@ -9,7 +9,7 @@ const StepHeader = ({ }: { title: string subtitle?: string - children?: React.ReactNode + children?: ReactNode }) => { return (
diff --git a/frontend/src/components/common/step-section/StepSection.tsx b/frontend/src/components/common/step-section/StepSection.tsx index a2295a858..0304a0bd0 100644 --- a/frontend/src/components/common/step-section/StepSection.tsx +++ b/frontend/src/components/common/step-section/StepSection.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import { ReactNode } from 'react' import styles from './StepSection.module.scss' @@ -6,7 +6,7 @@ const StepSection = ({ children, separator = true, }: { - children: React.ReactNode + children: ReactNode separator?: boolean }) => { return ( diff --git a/frontend/src/components/common/text-area/TextArea.tsx b/frontend/src/components/common/text-area/TextArea.tsx index 65ec1631f..372be3fd2 100644 --- a/frontend/src/components/common/text-area/TextArea.tsx +++ b/frontend/src/components/common/text-area/TextArea.tsx @@ -1,7 +1,7 @@ -import React, { useRef, useEffect } from 'react' import cx from 'classnames' -import TextareaAutosize from 'react-textarea-autosize' import escapeHTML from 'escape-html' +import { useRef, useEffect } from 'react' +import TextareaAutosize from 'react-textarea-autosize' import styles from './TextArea.module.scss' diff --git a/frontend/src/components/common/text-button/TextButton.tsx b/frontend/src/components/common/text-button/TextButton.tsx index 9cee3479a..33ab943ff 100644 --- a/frontend/src/components/common/text-button/TextButton.tsx +++ b/frontend/src/components/common/text-button/TextButton.tsx @@ -1,10 +1,10 @@ -import React from 'react' import cx from 'classnames' +import type { ButtonHTMLAttributes } from 'react' + import styles from './TextButton.module.scss' -interface TextButtonProps - extends React.ButtonHTMLAttributes { +interface TextButtonProps extends ButtonHTMLAttributes { // if true, remove text-decoration noUnderline?: boolean // if true, follow min-width of primary button diff --git a/frontend/src/components/common/text-input-with-button/TextInputWithButton.tsx b/frontend/src/components/common/text-input-with-button/TextInputWithButton.tsx index 9a6c35a7e..0ca908e07 100644 --- a/frontend/src/components/common/text-input-with-button/TextInputWithButton.tsx +++ b/frontend/src/components/common/text-input-with-button/TextInputWithButton.tsx @@ -1,23 +1,32 @@ -import React, { useState, MutableRefObject } from 'react' import cx from 'classnames' -import useIsMounted from 'components/custom-hooks/use-is-mounted' -import { PrimaryButton, TextInput } from 'components/common' +import { useState, MutableRefObject } from 'react' + +import type { + InputHTMLAttributes, + ReactNode, + FunctionComponent, + FormEvent, +} from 'react' + import styles from './TextInputWithButton.module.scss' +import { PrimaryButton, TextInput } from 'components/common' +import useIsMounted from 'components/custom-hooks/use-is-mounted' + interface TextInputWithButtonProps - extends Omit, 'onChange'> { + extends Omit, 'onChange'> { onChange: (value: string) => void onClick: () => void | Promise buttonDisabled?: boolean inputDisabled?: boolean textRef?: MutableRefObject - buttonLabel?: React.ReactNode - loadingButtonLabel?: React.ReactNode + buttonLabel?: ReactNode + loadingButtonLabel?: ReactNode errorMessage?: string | null } -const TextInputWithButton: React.FunctionComponent = ({ +const TextInputWithButton: FunctionComponent = ({ id, value, onChange, @@ -35,7 +44,7 @@ const TextInputWithButton: React.FunctionComponent = ( const [asyncLoading, setAsyncLoading] = useState(false) const isMounted = useIsMounted() - const asyncSubmit = async (e: React.FormEvent) => { + const asyncSubmit = async (e: FormEvent) => { e.preventDefault() setAsyncLoading(true) diff --git a/frontend/src/components/common/text-input/TextInput.tsx b/frontend/src/components/common/text-input/TextInput.tsx index 8fe8bb916..a69b48b7e 100644 --- a/frontend/src/components/common/text-input/TextInput.tsx +++ b/frontend/src/components/common/text-input/TextInput.tsx @@ -1,17 +1,18 @@ -import React from 'react' import cx from 'classnames' +import { forwardRef } from 'react' + +import type { ReactNode, ChangeEvent } from 'react' + import styles from './TextInput.module.scss' -const TextInput = React.forwardRef((props: any, ref: React.ReactNode) => { +const TextInput = forwardRef((props: any, ref: ReactNode) => { const { onChange, className, ...otherProps } = props return ( ) => - onChange(e.target.value) - } + onChange={(e: ChangeEvent) => onChange(e.target.value)} {...otherProps} /> ) diff --git a/frontend/src/components/common/title-bar/TitleBar.tsx b/frontend/src/components/common/title-bar/TitleBar.tsx index bed9eab08..2c6268391 100644 --- a/frontend/src/components/common/title-bar/TitleBar.tsx +++ b/frontend/src/components/common/title-bar/TitleBar.tsx @@ -1,11 +1,12 @@ -import React from 'react' +import type { ReactNode } from 'react' + import styles from './TitleBar.module.scss' const TitleBar = ({ children, title, }: { - children: React.ReactNode + children: ReactNode title: string }) => { return ( diff --git a/frontend/src/components/common/warning-block/WarningBlock.tsx b/frontend/src/components/common/warning-block/WarningBlock.tsx index eb315f39e..cf1950dc7 100644 --- a/frontend/src/components/common/warning-block/WarningBlock.tsx +++ b/frontend/src/components/common/warning-block/WarningBlock.tsx @@ -1,8 +1,11 @@ -import React from 'react' import cx from 'classnames' -import styles from './WarningBlock.module.scss' + +import type { ReactNode } from 'react' + import MessageBlock from '../message-block' +import styles from './WarningBlock.module.scss' + const WarningBlock = ({ className, children, @@ -12,7 +15,7 @@ const WarningBlock = ({ ...otherProps }: { className?: string - children?: React.ReactNode + children?: ReactNode absolute?: boolean onClose?: () => void title?: string diff --git a/frontend/src/components/custom-hooks/use-poll-campaign-stats.ts b/frontend/src/components/custom-hooks/use-poll-campaign-stats.ts index 1cedb2f5f..f1db98c58 100644 --- a/frontend/src/components/custom-hooks/use-poll-campaign-stats.ts +++ b/frontend/src/components/custom-hooks/use-poll-campaign-stats.ts @@ -1,7 +1,8 @@ -import { CampaignStats, Status } from 'classes' import { useState, useEffect, useCallback, useContext } from 'react' -import { getCampaignStats, getCampaignDetails } from 'services/campaign.service' + +import { CampaignStats, Status } from 'classes' import { CampaignContext } from 'contexts/campaign.context' +import { getCampaignStats, getCampaignDetails } from 'services/campaign.service' function usePollCampaignStats() { const { campaign, setCampaign } = useContext(CampaignContext) diff --git a/frontend/src/components/dashboard/Dashboard.tsx b/frontend/src/components/dashboard/Dashboard.tsx index ebc2d1f0b..f12b9d3b9 100644 --- a/frontend/src/components/dashboard/Dashboard.tsx +++ b/frontend/src/components/dashboard/Dashboard.tsx @@ -1,15 +1,17 @@ -import React from 'react' import { Switch, Route } from 'react-router-dom' -import ModalContextProvider from 'contexts/modal.context' -import CampaignContextProvider from 'contexts/campaign.context' -import FinishLaterContextProvider from 'contexts/finish-later.modal.context' -import Error from 'components/error' -import { InfoBanner, NavBar } from 'components/common' import Campaigns from './campaigns' + import Create from './create' + import Settings from './settings' + +import { InfoBanner, NavBar } from 'components/common' +import Error from 'components/error' import { INFO_BANNER } from 'config' +import CampaignContextProvider from 'contexts/campaign.context' +import FinishLaterContextProvider from 'contexts/finish-later.modal.context' +import ModalContextProvider from 'contexts/modal.context' const Dashboard = () => { return ( diff --git a/frontend/src/components/dashboard/campaigns/Campaigns.tsx b/frontend/src/components/dashboard/campaigns/Campaigns.tsx index 5f9c16dcd..55af630f9 100644 --- a/frontend/src/components/dashboard/campaigns/Campaigns.tsx +++ b/frontend/src/components/dashboard/campaigns/Campaigns.tsx @@ -1,33 +1,41 @@ -import React, { useEffect, useState, useContext, useCallback } from 'react' -import { useHistory } from 'react-router-dom' +import { Trans } from '@lingui/macro' + import cx from 'classnames' -import Moment from 'react-moment' + import { capitalize } from 'lodash' -import { Trans } from '@lingui/macro' -import { ModalContext } from 'contexts/modal.context' -import { AuthContext } from 'contexts/auth.context' +import { useEffect, useState, useContext, useCallback } from 'react' + +import type { MouseEvent as ReactMouseEvent } from 'react' +import Moment from 'react-moment' +import { useHistory } from 'react-router-dom' + +import DuplicateCampaignModal from '../create/duplicate-campaign-modal' + +import styles from './Campaigns.module.scss' + +import AnnouncementModal from './announcement-modal' + +import EmptyDashboardImg from 'assets/img/empty-dashboard.svg' +import { Campaign, channelIcons, ChannelType, Status } from 'classes' import { Pagination, TitleBar, PrimaryButton, ExportRecipients, } from 'components/common' -import { getCampaigns } from 'services/campaign.service' -import { GA_USER_EVENTS, sendUserEvent } from 'services/ga.service' -import { Campaign, channelIcons, ChannelType, Status } from 'classes' +import useIsMounted from 'components/custom-hooks/use-is-mounted' import CreateCampaign from 'components/dashboard/create/create-modal' +import CreateDemoModal from 'components/dashboard/demo/create-demo-modal' +import DemoBar from 'components/dashboard/demo/demo-bar/DemoBar' +import { ANNOUNCEMENT, getAnnouncementVersion } from 'config' +import { AuthContext } from 'contexts/auth.context' +import { ModalContext } from 'contexts/modal.context' -import EmptyDashboardImg from 'assets/img/empty-dashboard.svg' -import styles from './Campaigns.module.scss' +import { getCampaigns } from 'services/campaign.service' +import { GA_USER_EVENTS, sendUserEvent } from 'services/ga.service' -import DemoBar from 'components/dashboard/demo/demo-bar/DemoBar' -import CreateDemoModal from 'components/dashboard/demo/create-demo-modal' import { getUserSettings } from 'services/settings.service' -import DuplicateCampaignModal from '../create/duplicate-campaign-modal' -import AnnouncementModal from './announcement-modal' -import { ANNOUNCEMENT, getAnnouncementVersion } from 'config' -import useIsMounted from 'components/custom-hooks/use-is-mounted' const ITEMS_PER_PAGE = 10 @@ -204,7 +212,7 @@ const Campaigns = () => { return (
) => { + onClick={(event: ReactMouseEvent) => { event.stopPropagation() sendUserEvent(GA_USER_EVENTS.OPEN_DUPLICATE_MODAL, campaign.type) const modal = campaign.demoMessageLimit ? ( diff --git a/frontend/src/components/dashboard/campaigns/announcement-modal/AnnouncementModal.tsx b/frontend/src/components/dashboard/campaigns/announcement-modal/AnnouncementModal.tsx index 284295fde..0ea3d1fbd 100644 --- a/frontend/src/components/dashboard/campaigns/announcement-modal/AnnouncementModal.tsx +++ b/frontend/src/components/dashboard/campaigns/announcement-modal/AnnouncementModal.tsx @@ -1,14 +1,17 @@ -import React, { useContext, useState, useEffect } from 'react' -import { updateAnnouncementVersion } from 'services/settings.service' +import { i18n } from '@lingui/core' -import { ModalContext } from 'contexts/modal.context' +import { useContext, useState, useEffect } from 'react' + +import styles from './AnnouncementModal.module.scss' -import { i18n } from '@lingui/core' -import { ANNOUNCEMENT, getAnnouncementVersion } from 'config' -import { ErrorBlock } from 'components/common' import GraphicAnnouncementModal from './GraphicAnnouncementModal' + import VideoAnnouncementModal from './VideoAnnouncementModal' -import styles from './AnnouncementModal.module.scss' + +import { ErrorBlock } from 'components/common' +import { ANNOUNCEMENT, getAnnouncementVersion } from 'config' +import { ModalContext } from 'contexts/modal.context' +import { updateAnnouncementVersion } from 'services/settings.service' export type AnnouncementModalProps = { title: string diff --git a/frontend/src/components/dashboard/campaigns/announcement-modal/AnnouncementModalOptions.tsx b/frontend/src/components/dashboard/campaigns/announcement-modal/AnnouncementModalOptions.tsx index 6f84b9708..624c6c75d 100644 --- a/frontend/src/components/dashboard/campaigns/announcement-modal/AnnouncementModalOptions.tsx +++ b/frontend/src/components/dashboard/campaigns/announcement-modal/AnnouncementModalOptions.tsx @@ -1,10 +1,11 @@ -import React from 'react' -import { TextButton, PrimaryButton } from 'components/common' -import { OutboundLink } from 'react-ga' import cx from 'classnames' +import { OutboundLink } from 'react-ga' + import styles from './AnnouncementModalOptions.module.scss' +import { TextButton, PrimaryButton } from 'components/common' + interface AnnouncementModalOptionsProps { primaryButtonUrl: string primaryButtonText: string diff --git a/frontend/src/components/dashboard/campaigns/announcement-modal/GraphicAnnouncementModal.tsx b/frontend/src/components/dashboard/campaigns/announcement-modal/GraphicAnnouncementModal.tsx index 919a9163e..90e5142d1 100644 --- a/frontend/src/components/dashboard/campaigns/announcement-modal/GraphicAnnouncementModal.tsx +++ b/frontend/src/components/dashboard/campaigns/announcement-modal/GraphicAnnouncementModal.tsx @@ -1,8 +1,6 @@ -import React from 'react' - -import styles from './GraphicAnnouncementModal.module.scss' -import AnnouncementModalOptions from './AnnouncementModalOptions' import { AnnouncementModalProps } from './AnnouncementModal' +import AnnouncementModalOptions from './AnnouncementModalOptions' +import styles from './GraphicAnnouncementModal.module.scss' const GraphicAnnouncementModal = ({ title, diff --git a/frontend/src/components/dashboard/campaigns/announcement-modal/VideoAnnouncementModal.tsx b/frontend/src/components/dashboard/campaigns/announcement-modal/VideoAnnouncementModal.tsx index 880cd53f8..02ecf0e46 100644 --- a/frontend/src/components/dashboard/campaigns/announcement-modal/VideoAnnouncementModal.tsx +++ b/frontend/src/components/dashboard/campaigns/announcement-modal/VideoAnnouncementModal.tsx @@ -1,9 +1,8 @@ -import React from 'react' import ReactPlayer from 'react-player/lazy' -import styles from './VideoAnnouncementModal.module.scss' import { AnnouncementModalProps } from './AnnouncementModal' import AnnouncementModalOptions from './AnnouncementModalOptions' +import styles from './VideoAnnouncementModal.module.scss' const VideoAnnouncementModal = ({ title, diff --git a/frontend/src/components/dashboard/create/Create.module.scss b/frontend/src/components/dashboard/create/Create.module.scss index 39e218602..9d70685ec 100644 --- a/frontend/src/components/dashboard/create/Create.module.scss +++ b/frontend/src/components/dashboard/create/Create.module.scss @@ -142,12 +142,6 @@ } } -.characterCount { - @extend %body-2; - text-align: right; - color: $light-grey; -} - .protectedPreview { border-radius: 1.25rem; } diff --git a/frontend/src/components/dashboard/create/Create.tsx b/frontend/src/components/dashboard/create/Create.tsx index 89719bb9d..add5bf977 100644 --- a/frontend/src/components/dashboard/create/Create.tsx +++ b/frontend/src/components/dashboard/create/Create.tsx @@ -1,19 +1,26 @@ -import React, { useEffect, useState, useContext } from 'react' -import { useParams, useHistory } from 'react-router-dom' import cx from 'classnames' -import { CampaignContext } from 'contexts/campaign.context' -import { FinishLaterModalContext } from 'contexts/finish-later.modal.context' +import { useEffect, useState, useContext } from 'react' + +import { useParams, useHistory } from 'react-router-dom' + +import styles from './Create.module.scss' + +import EmailCreate from './email/EmailCreate' + +import SMSCreate from './sms/SMSCreate' + +import TelegramCreate from './telegram/TelegramCreate' + import { ChannelType, Status } from 'classes' import { TitleBar, PrimaryButton } from 'components/common' import DemoInfoBanner from 'components/dashboard/demo/demo-info-banner/DemoInfoBanner' import Error from 'components/error' +import { CampaignContext } from 'contexts/campaign.context' +import { FinishLaterModalContext } from 'contexts/finish-later.modal.context' + import { getCampaignDetails } from 'services/campaign.service' import { GA_USER_EVENTS, sendUserEvent } from 'services/ga.service' -import SMSCreate from './sms/SMSCreate' -import EmailCreate from './email/EmailCreate' -import TelegramCreate from './telegram/TelegramCreate' -import styles from './Create.module.scss' const Create = () => { const { id } = useParams<{ id: string }>() diff --git a/frontend/src/components/dashboard/create/common/BodyTemplate/BodyTemplate.module.scss b/frontend/src/components/dashboard/create/common/BodyTemplate/BodyTemplate.module.scss new file mode 100644 index 000000000..22acc8332 --- /dev/null +++ b/frontend/src/components/dashboard/create/common/BodyTemplate/BodyTemplate.module.scss @@ -0,0 +1,8 @@ +@import 'styles/_variables'; +@import 'styles/_mixins'; + +.characterCount { + @extend %body-2; + text-align: right; + color: $light-grey; +} diff --git a/frontend/src/components/dashboard/create/sms/SMSTemplate.tsx b/frontend/src/components/dashboard/create/common/BodyTemplate/BodyTemplate.tsx similarity index 62% rename from frontend/src/components/dashboard/create/sms/SMSTemplate.tsx rename to frontend/src/components/dashboard/create/common/BodyTemplate/BodyTemplate.tsx index ac9f06e0f..9996d2b23 100644 --- a/frontend/src/components/dashboard/create/sms/SMSTemplate.tsx +++ b/frontend/src/components/dashboard/create/common/BodyTemplate/BodyTemplate.tsx @@ -1,8 +1,12 @@ -import React, { useState, useCallback, useEffect, useContext } from 'react' +import { useState, useCallback, useEffect, useContext } from 'react' + +import type { Dispatch, SetStateAction } from 'react' + import { useParams } from 'react-router-dom' -import { FinishLaterModalContext } from 'contexts/finish-later.modal.context' -import { CampaignContext } from 'contexts/campaign.context' +import styles from './BodyTemplate.module.scss' + +import { SMSProgress, TelegramProgress } from 'classes' import { TextArea, NextButton, @@ -11,40 +15,56 @@ import { StepSection, } from 'components/common' import SaveDraftModal from 'components/dashboard/create/save-draft-modal' -import { exceedsCharacterThreshold, saveTemplate } from 'services/sms.service' - -import type { Dispatch, SetStateAction } from 'react' -import type { SMSProgress } from 'classes' - -import styles from '../Create.module.scss' +import { CampaignContext } from 'contexts/campaign.context' +import { FinishLaterModalContext } from 'contexts/finish-later.modal.context' -const SMSTemplate = ({ +function BodyTemplate({ setActiveStep, + warnCharacterCount, + errorCharacterCount, + saveTemplate, }: { - setActiveStep: Dispatch> -}) => { + setActiveStep: + | Dispatch> + | Dispatch> + warnCharacterCount: number + errorCharacterCount: number + saveTemplate: ( + campaignId: number, + body: string + ) => Promise<{ + numRecipients: number + updatedTemplate?: { body: string; params: string[] } + }> +}) { const { campaign, updateCampaign } = useContext(CampaignContext) const { setFinishLaterContent } = useContext(FinishLaterModalContext) const [body, setBody] = useState(replaceNewLines(campaign.body)) - const [errorMsg, setErrorMsg] = useState(null) + const [errorMsg, setErrorMsg] = useState(null) const { id: campaignId } = useParams<{ id: string }>() useEffect(() => { - if (exceedsCharacterThreshold(body)) { - setErrorMsg( - ( - - Your template has more than 1000 characters. Messages which are - longer than 1600 characters (including keywords) can't - be sent. Consider making your message short and sweet to make it - easier to read on a mobile device. - - ) as any + let errorMsg = null + if (body.length > errorCharacterCount) { + errorMsg = ( + + Your template has more than {errorCharacterCount} characters + and can't be sent. Consider making your message short and sweet + to make it easier to read on a mobile device. + + ) + } else if (body.length > warnCharacterCount) { + errorMsg = ( + + Your template has more than {warnCharacterCount} characters. Messages + which are longer than {errorCharacterCount} characters + (including keywords) can't be sent. Consider making your message + short and sweet to make it easier to read on a mobile device. + ) - } else { - setErrorMsg(null) } - }, [body]) + setErrorMsg(errorMsg) + }, [body.length, errorCharacterCount, warnCharacterCount]) const handleSaveTemplate = useCallback(async (): Promise => { setErrorMsg(null) @@ -62,12 +82,12 @@ const SMSTemplate = ({ params: updatedTemplate.params, numRecipients, }) - setActiveStep((s) => s + 1) + setActiveStep((s: SMSProgress | TelegramProgress) => s + 1) } } catch (err) { setErrorMsg(err.message) } - }, [body, campaignId, setActiveStep, updateCampaign]) + }, [body, campaignId, setActiveStep, updateCampaign, saveTemplate]) // Set callback for finish later button useEffect(() => { @@ -89,7 +109,7 @@ const SMSTemplate = ({ return () => { setFinishLaterContent(null) } - }, [body, campaignId, setFinishLaterContent]) + }, [body, campaignId, setFinishLaterContent, saveTemplate]) function replaceNewLines(body: string): string { return (body || '').replace(//g, '\n') @@ -129,10 +149,13 @@ const SMSTemplate = ({

{body.length} characters

- + errorCharacterCount} + onClick={handleSaveTemplate} + /> {errorMsg} ) } -export default SMSTemplate +export default BodyTemplate diff --git a/frontend/src/components/dashboard/create/common/BodyTemplate/index.tsx b/frontend/src/components/dashboard/create/common/BodyTemplate/index.tsx new file mode 100644 index 000000000..d706aa933 --- /dev/null +++ b/frontend/src/components/dashboard/create/common/BodyTemplate/index.tsx @@ -0,0 +1 @@ +export { default } from './BodyTemplate' diff --git a/frontend/src/components/dashboard/create/common/index.ts b/frontend/src/components/dashboard/create/common/index.ts new file mode 100644 index 000000000..0c0b2f525 --- /dev/null +++ b/frontend/src/components/dashboard/create/common/index.ts @@ -0,0 +1 @@ +export { default as BodyTemplate } from './BodyTemplate' diff --git a/frontend/src/components/dashboard/create/sms/tests/SMSTemplate.test.tsx b/frontend/src/components/dashboard/create/common/tests/BodyTemplate.test.tsx similarity index 66% rename from frontend/src/components/dashboard/create/sms/tests/SMSTemplate.test.tsx rename to frontend/src/components/dashboard/create/common/tests/BodyTemplate.test.tsx index f20c028bb..38613ecaa 100644 --- a/frontend/src/components/dashboard/create/sms/tests/SMSTemplate.test.tsx +++ b/frontend/src/components/dashboard/create/common/tests/BodyTemplate.test.tsx @@ -1,11 +1,15 @@ -import React from 'react' -import { screen, mockCommonApis, server, render, Campaign } from 'test-utils' -import SMSTemplate from '../SMSTemplate' -import CampaignContextProvider from 'contexts/campaign.context' -import FinishLaterModalContextProvider from 'contexts/finish-later.modal.context' import userEvent from '@testing-library/user-event' + import { Route } from 'react-router-dom' +import BodyTemplate from '../BodyTemplate' + +import CampaignContextProvider from 'contexts/campaign.context' +import FinishLaterModalContextProvider from 'contexts/finish-later.modal.context' +import { saveTemplate as saveSmsTemplate } from 'services/sms.service' +import { saveTemplate as saveTelegramTemplate } from 'services/telegram.service' +import { screen, mockCommonApis, server, render, Campaign } from 'test-utils' + const TEST_SMS_CAMPAIGN: Campaign = { id: 1, name: 'Test SMS campaign', @@ -30,14 +34,21 @@ function mockApis() { return handlers } -function renderTemplatePage() { +function renderTemplatePage( + saveTemplate: typeof BodyTemplate.arguments.saveTemplate +) { const setActiveStep = jest.fn() render( - + , @@ -50,7 +61,7 @@ function renderTemplatePage() { test('displays the necessary elements', async () => { // Setup server.use(...mockApis()) - renderTemplatePage() + renderTemplatePage(jest.fn()) // Wait for the component to fully load const heading = await screen.findByRole('heading', { @@ -77,7 +88,7 @@ test('displays the necessary elements', async () => { test('next button is disabled when template is empty', async () => { // Setup server.use(...mockApis()) - renderTemplatePage() + renderTemplatePage(jest.fn()) // Wait for the component to fully load const templateTextbox = await screen.findByRole('textbox', { @@ -102,7 +113,7 @@ test('next button is disabled when template is empty', async () => { test('next button is enabled when the template is filled', async () => { // Setup server.use(...mockApis()) - renderTemplatePage() + renderTemplatePage(jest.fn()) // Wait for the component to fully load const templateTextbox = await screen.findByRole('textbox', { @@ -124,7 +135,7 @@ test('next button is enabled when the template is filled', async () => { test('character count text reflects the actual number of characters in the textbox', async () => { // Setup server.use(...mockApis()) - renderTemplatePage() + renderTemplatePage(jest.fn()) // Wait for the component to fully load const templateTextbox = await screen.findByRole('textbox', { @@ -146,37 +157,56 @@ test('character count text reflects the actual number of characters in the textb } }) -test('displays an error if the template is invalid', async () => { - // Setup - jest.spyOn(console, 'error').mockImplementation(() => { - // Do nothing. Mock console.error to silence expected errors - // due to submitting invalid templates to the API +describe('displays an error if the template is invalid', () => { + async function runTest() { + // Wait for the component to fully load + const templateTextbox = await screen.findByRole('textbox', { + name: /message/i, + }) + const nextButton = screen.getByRole('button', { name: /next/i }) + + // Test against various invalid templates + const TEST_TEMPLATES = ['', '