diff --git a/backend/package-lock.json b/backend/package-lock.json index 28556e91a2..9aa4c18780 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -48,6 +48,7 @@ "sequelize": "6.21.2", "sequelize-cli-typescript": "^3.2.0-c", "stripe": "10.0.0", + "superagent": "^8.0.0", "swagger-ui-dist": "4.1.3", "uuid": "^8.3.2", "validator": "^13.7.0" @@ -59,6 +60,7 @@ "@types/jest": "^27.4.0", "@types/node": "^17.0.21", "@types/sanitize-html": "^2.6.2", + "@types/superagent": "^4.1.15", "@typescript-eslint/eslint-plugin": "^5.17.0", "@typescript-eslint/parser": "^5.17.0", "copyfiles": "2.4.1", @@ -75,7 +77,6 @@ "nodemon": "2.0.4", "prettier": "^2.5.1", "rdme": "^7.2.0", - "superagent": "^7.1.2", "supertest": "^6.2.2", "ts-jest": "^27.1.3", "ts-node": "10.6.0", @@ -3648,6 +3649,12 @@ "resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.0.tgz", "integrity": "sha512-wJsiX1tosQ+J5+bY5LrSahHxr2wT+uME5UDwdN1kg4frt40euqA+wzECkmq4t5QbveHiJepfdThgQrPw6KiSlg==" }, + "node_modules/@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 + }, "node_modules/@types/debug": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", @@ -3761,6 +3768,16 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/superagent": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.15.tgz", + "integrity": "sha512-mu/N4uvfDN2zVQQ5AYJI/g4qxn2bHB6521t1UuH09ShNWjebTqN0ZFuYK9uYjcgmI0dTQEs+Owi1EO6U0OkOZQ==", + "dev": true, + "dependencies": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, "node_modules/@types/validator": { "version": "13.7.4", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.4.tgz", @@ -4389,8 +4406,7 @@ "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, "node_modules/ast-types": { "version": "0.13.4", @@ -5690,8 +5706,7 @@ "node_modules/component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", - "dev": true + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" }, "node_modules/component-type": { "version": "1.2.1", @@ -5828,8 +5843,7 @@ "node_modules/cookiejar": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz", - "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==", - "dev": true + "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==" }, "node_modules/copyfiles": { "version": "2.4.1", @@ -6411,7 +6425,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.3.tgz", "integrity": "sha512-K7i4zNfT2kgQz3GylDw40ot9GAE47sFZ9EXHFSPP6zONLgH6kWXE0KWJchkbQJLBkRazq4APwZ4OwiFFlT95OQ==", - "dev": true, "dependencies": { "asap": "^2.0.0", "wrappy": "1" @@ -7751,8 +7764,7 @@ "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, "node_modules/fast-text-encoding": { "version": "1.0.4", @@ -8658,7 +8670,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", - "dev": true, "engines": { "node": ">=8" } @@ -16681,10 +16692,9 @@ "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==" }, "node_modules/superagent": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.5.tgz", - "integrity": "sha512-HQYyGuDRFGmZ6GNC4hq2f37KnsY9Lr0/R1marNZTgMweVDQLTLJJ6DGQ9Tj/xVVs5HEnop9EMmTbywb5P30aqw==", - "dev": true, + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.0.tgz", + "integrity": "sha512-iudipXEel+SzlP9y29UBWGDjB+Zzag+eeA1iLosaR2YHBRr1Q1kC29iBrF2zIVD9fqVbpZnXkN/VJmwFMVyNWg==", "dependencies": { "component-emitter": "^1.3.0", "cookiejar": "^2.1.3", @@ -16693,7 +16703,7 @@ "form-data": "^4.0.0", "formidable": "^2.0.1", "methods": "^1.1.2", - "mime": "^2.5.0", + "mime": "2.6.0", "qs": "^6.10.3", "readable-stream": "^3.6.0", "semver": "^7.3.7" @@ -16706,7 +16716,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -16720,7 +16729,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz", "integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==", - "dev": true, "dependencies": { "dezalgo": "1.0.3", "hexoid": "1.0.0", @@ -16735,7 +16743,6 @@ "version": "6.9.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz", "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==", - "dev": true, "engines": { "node": ">=0.6" }, @@ -16747,7 +16754,6 @@ "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dev": true, "dependencies": { "side-channel": "^1.0.4" }, @@ -16762,7 +16768,6 @@ "version": "7.3.7", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -16786,99 +16791,6 @@ "node": ">=6.4.0" } }, - "node_modules/supertest/node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/supertest/node_modules/formidable": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz", - "integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==", - "dev": true, - "dependencies": { - "dezalgo": "1.0.3", - "hexoid": "1.0.0", - "once": "1.4.0", - "qs": "6.9.3" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" - } - }, - "node_modules/supertest/node_modules/formidable/node_modules/qs": { - "version": "6.9.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz", - "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==", - "dev": true, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/supertest/node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dev": true, - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/supertest/node_modules/semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/supertest/node_modules/superagent": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.0.tgz", - "integrity": "sha512-iudipXEel+SzlP9y29UBWGDjB+Zzag+eeA1iLosaR2YHBRr1Q1kC29iBrF2zIVD9fqVbpZnXkN/VJmwFMVyNWg==", - "dev": true, - "dependencies": { - "component-emitter": "^1.3.0", - "cookiejar": "^2.1.3", - "debug": "^4.3.4", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^2.0.1", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.10.3", - "readable-stream": "^3.6.0", - "semver": "^7.3.7" - }, - "engines": { - "node": ">=6.4.0 <13 || >=14" - } - }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -21557,6 +21469,12 @@ "resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.0.tgz", "integrity": "sha512-wJsiX1tosQ+J5+bY5LrSahHxr2wT+uME5UDwdN1kg4frt40euqA+wzECkmq4t5QbveHiJepfdThgQrPw6KiSlg==" }, + "@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/debug": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", @@ -21670,6 +21588,16 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "@types/superagent": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.15.tgz", + "integrity": "sha512-mu/N4uvfDN2zVQQ5AYJI/g4qxn2bHB6521t1UuH09ShNWjebTqN0ZFuYK9uYjcgmI0dTQEs+Owi1EO6U0OkOZQ==", + "dev": true, + "requires": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, "@types/validator": { "version": "13.7.4", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.4.tgz", @@ -22122,8 +22050,7 @@ "asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, "ast-types": { "version": "0.13.4", @@ -23107,8 +23034,7 @@ "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", - "dev": true + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" }, "component-type": { "version": "1.2.1", @@ -23224,8 +23150,7 @@ "cookiejar": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz", - "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==", - "dev": true + "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==" }, "copyfiles": { "version": "2.4.1", @@ -23673,7 +23598,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.3.tgz", "integrity": "sha512-K7i4zNfT2kgQz3GylDw40ot9GAE47sFZ9EXHFSPP6zONLgH6kWXE0KWJchkbQJLBkRazq4APwZ4OwiFFlT95OQ==", - "dev": true, "requires": { "asap": "^2.0.0", "wrappy": "1" @@ -24757,8 +24681,7 @@ "fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, "fast-text-encoding": { "version": "1.0.4", @@ -25454,8 +25377,7 @@ "hexoid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", - "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", - "dev": true + "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==" }, "highlight.js": { "version": "10.7.3", @@ -31523,10 +31445,9 @@ "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==" }, "superagent": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.5.tgz", - "integrity": "sha512-HQYyGuDRFGmZ6GNC4hq2f37KnsY9Lr0/R1marNZTgMweVDQLTLJJ6DGQ9Tj/xVVs5HEnop9EMmTbywb5P30aqw==", - "dev": true, + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.0.tgz", + "integrity": "sha512-iudipXEel+SzlP9y29UBWGDjB+Zzag+eeA1iLosaR2YHBRr1Q1kC29iBrF2zIVD9fqVbpZnXkN/VJmwFMVyNWg==", "requires": { "component-emitter": "^1.3.0", "cookiejar": "^2.1.3", @@ -31535,7 +31456,7 @@ "form-data": "^4.0.0", "formidable": "^2.0.1", "methods": "^1.1.2", - "mime": "^2.5.0", + "mime": "2.6.0", "qs": "^6.10.3", "readable-stream": "^3.6.0", "semver": "^7.3.7" @@ -31545,7 +31466,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -31556,7 +31476,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz", "integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==", - "dev": true, "requires": { "dezalgo": "1.0.3", "hexoid": "1.0.0", @@ -31567,8 +31486,7 @@ "qs": { "version": "6.9.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz", - "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==", - "dev": true + "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==" } } }, @@ -31576,7 +31494,6 @@ "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dev": true, "requires": { "side-channel": "^1.0.4" } @@ -31585,7 +31502,6 @@ "version": "7.3.7", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dev": true, "requires": { "lru-cache": "^6.0.0" } @@ -31600,76 +31516,6 @@ "requires": { "methods": "^1.1.2", "superagent": "^8.0.0" - }, - "dependencies": { - "form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - }, - "formidable": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz", - "integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==", - "dev": true, - "requires": { - "dezalgo": "1.0.3", - "hexoid": "1.0.0", - "once": "1.4.0", - "qs": "6.9.3" - }, - "dependencies": { - "qs": { - "version": "6.9.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz", - "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==", - "dev": true - } - } - }, - "qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dev": true, - "requires": { - "side-channel": "^1.0.4" - } - }, - "semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "superagent": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.0.tgz", - "integrity": "sha512-iudipXEel+SzlP9y29UBWGDjB+Zzag+eeA1iLosaR2YHBRr1Q1kC29iBrF2zIVD9fqVbpZnXkN/VJmwFMVyNWg==", - "dev": true, - "requires": { - "component-emitter": "^1.3.0", - "cookiejar": "^2.1.3", - "debug": "^4.3.4", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^2.0.1", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.10.3", - "readable-stream": "^3.6.0", - "semver": "^7.3.7" - } - } } }, "supports-color": { diff --git a/backend/package.json b/backend/package.json index fa9d222aad..a351211993 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,8 @@ "db:publish": "bash ./util/publish-db.sh", "docs": "bash ./util/publish-docs.sh", "sequelize-cli:source": "npm run build:setenv:dev && npm run build && ./node_modules/.bin/sequelize --config src/database/sequelize-cli-config.ts --migrations-source-path src/database/migrations", + "sequelize:migrate:staging": "npm run build:setenv:staging && npm run build && ./node_modules/.bin/sequelize --config src/database/sequelize-cli-config.ts --migrations-source-path src/database/migrations db:migrate", + "sequelize:migrate:prod": "npm run build:setenv:prod && npm run build && ./node_modules/.bin/sequelize --config src/database/sequelize-cli-config.ts --migrations-source-path src/database/migrations db:migrate", "sequelize-cli:build": "./node_modules/.bin/sequelize --config database/sequelize-cli-config.js --migrations-compiled-path database/migrations", "stripe:login": "stripe login", "stripe:start": "stripe listen --forward-to localhost:8080/api/plan/stripe/webhook", @@ -69,6 +71,7 @@ "sequelize": "6.21.2", "sequelize-cli-typescript": "^3.2.0-c", "stripe": "10.0.0", + "superagent": "^8.0.0", "swagger-ui-dist": "4.1.3", "uuid": "^8.3.2", "validator": "^13.7.0" @@ -81,6 +84,7 @@ "@types/jest": "^27.4.0", "@types/node": "^17.0.21", "@types/sanitize-html": "^2.6.2", + "@types/superagent": "^4.1.15", "@typescript-eslint/eslint-plugin": "^5.17.0", "@typescript-eslint/parser": "^5.17.0", "copyfiles": "2.4.1", @@ -97,7 +101,6 @@ "nodemon": "2.0.4", "prettier": "^2.5.1", "rdme": "^7.2.0", - "superagent": "^7.1.2", "supertest": "^6.2.2", "ts-jest": "^27.1.3", "ts-node": "10.6.0", diff --git a/backend/src/api/automation/automationCreate.ts b/backend/src/api/automation/automationCreate.ts new file mode 100644 index 0000000000..343baaa236 --- /dev/null +++ b/backend/src/api/automation/automationCreate.ts @@ -0,0 +1,32 @@ +import PermissionChecker from '../../services/user/permissionChecker' +import Permissions from '../../security/permissions' +import AutomationService from '../../services/automationService' +import track from '../../segment/track' +import ApiResponseHandler from '../apiResponseHandler' + +/** + * POST /tenant/{tenantId}/automation + * @summary Create an automation + * @tag Automations + * @security Bearer + * @description Create a new automation for the tenant. + * @pathParam {string} tenantId - Your workspace/tenant ID + * @bodyContent {AutomationCreateInput} application/json + * @response 200 - Ok + * @responseContent {Automation} 200.application/json + * @responseExample {Automation} 200.application/json.Automation + * @response 401 - Unauthorized + * @response 429 - Too many requests + */ +export default async (req, res) => { + try { + new PermissionChecker(req).validateHas(Permissions.values.automationCreate) + const payload = await new AutomationService(req).create(req.body.data) + + track('Automation Created', { ...payload }, { ...req }) + + await ApiResponseHandler.success(req, res, payload) + } catch (error) { + await ApiResponseHandler.error(req, res, error) + } +} diff --git a/backend/src/api/automation/automationDestroy.ts b/backend/src/api/automation/automationDestroy.ts new file mode 100644 index 0000000000..8967f38dd8 --- /dev/null +++ b/backend/src/api/automation/automationDestroy.ts @@ -0,0 +1,30 @@ +import PermissionChecker from '../../services/user/permissionChecker' +import Permissions from '../../security/permissions' +import AutomationService from '../../services/automationService' +import track from '../../segment/track' +import ApiResponseHandler from '../apiResponseHandler' + +/** + * DELETE /tenant/{tenantId}/automation/{automationId} + * @summary Destroys an existing automation + * @tag Automations + * @security Bearer + * @description Destroys an existing automation in the tenant. + * @pathParam {string} tenantId - Your workspace/tenant ID + * @pathParam {string} automationId - Automation ID that you want to update + * @response 204 - Ok - No content + * @response 401 - Unauthorized + * @response 429 - Too many requests + */ +export default async (req, res) => { + try { + new PermissionChecker(req).validateHas(Permissions.values.automationDestroy) + await new AutomationService(req).destroy(req.params.automationId) + + track('Automation Destroyed', { id: req.params.automationId }, { ...req }) + + await ApiResponseHandler.success(req, res, true, 204) + } catch (error) { + await ApiResponseHandler.error(req, res, error) + } +} diff --git a/backend/src/api/automation/automationExecutionFind.ts b/backend/src/api/automation/automationExecutionFind.ts new file mode 100644 index 0000000000..01970bf93d --- /dev/null +++ b/backend/src/api/automation/automationExecutionFind.ts @@ -0,0 +1,45 @@ +import PermissionChecker from '../../services/user/permissionChecker' +import Permissions from '../../security/permissions' +import ApiResponseHandler from '../apiResponseHandler' +import AutomationExecutionService from '../../services/automationExecutionService' + +/** + * GET /tenant/{tenantId}/automation/{automationId}/executions + * @summary Get all automation execution history for tenant and automation + * @tag Automations + * @security Bearer + * @description Get all automation execution history for tenant and automation + * @pathParam {string} tenantId - Your workspace/tenant ID + * @pathParam {string} automationId - Your workspace/tenant ID + * @queryParam {integer} [offset=0] - How many elements from the beginning would you like to skip + * @queryParam {integer} [limit=10] - How many elements would you like to fetch + * @response 200 - Ok + * @responseContent {AutomationExecutionPage} 200.application/json + * @responseExample {AutomationExecutionPage} 200.application/json.AutomationExecutionPage + * @response 401 - Unauthorized + * @response 429 - Too many requests + */ +export default async (req, res) => { + try { + new PermissionChecker(req).validateHas(Permissions.values.automationRead) + + let offset = 0 + if (req.query.offset) { + offset = parseInt(req.query.offset, 10) + } + let limit = 10 + if (req.query.limit) { + limit = parseInt(req.query.limit, 10) + } + + const payload = await new AutomationExecutionService(req).findAndCountAll({ + automationId: req.params.automationId, + offset, + limit, + }) + + await ApiResponseHandler.success(req, res, payload) + } catch (error) { + await ApiResponseHandler.error(req, res, error) + } +} diff --git a/backend/src/api/automation/automationFind.ts b/backend/src/api/automation/automationFind.ts new file mode 100644 index 0000000000..27d351afd9 --- /dev/null +++ b/backend/src/api/automation/automationFind.ts @@ -0,0 +1,29 @@ +import PermissionChecker from '../../services/user/permissionChecker' +import Permissions from '../../security/permissions' +import AutomationService from '../../services/automationService' +import ApiResponseHandler from '../apiResponseHandler' + +/** + * GET /tenant/{tenantId}/automation/{automationId} + * @summary Get an existing automation data + * @tag Automations + * @security Bearer + * @description Get an existing automation data in the tenant. + * @pathParam {string} tenantId - Your workspace/tenant ID + * @pathParam {string} automationId - Automation ID that you want to find + * @response 200 - Ok + * @responseContent {Automation} 200.application/json + * @responseExample {Automation} 200.application/json.Automation + * @response 401 - Unauthorized + * @response 429 - Too many requests + */ +export default async (req, res) => { + try { + new PermissionChecker(req).validateHas(Permissions.values.automationRead) + const payload = await new AutomationService(req).findById(req.params.automationId) + + await ApiResponseHandler.success(req, res, payload) + } catch (error) { + await ApiResponseHandler.error(req, res, error) + } +} diff --git a/backend/src/api/automation/automationList.ts b/backend/src/api/automation/automationList.ts new file mode 100644 index 0000000000..149bf60825 --- /dev/null +++ b/backend/src/api/automation/automationList.ts @@ -0,0 +1,59 @@ +import PermissionChecker from '../../services/user/permissionChecker' +import Permissions from '../../security/permissions' +import AutomationService from '../../services/automationService' +import ApiResponseHandler from '../apiResponseHandler' +import { + AutomationCriteria, + AutomationState, + AutomationTrigger, + AutomationType, +} from '../../types/automationTypes' + +/** + * GET /tenant/{tenantId}/automation + * @summary Get all automation data for tenant + * @tag Automations + * @security Bearer + * @description Get all existing automation data for tenant. + * @pathParam {string} tenantId - Your workspace/tenant ID + * @queryParam {string} [filter[type]] - Filter by type of automation + * @queryParam {string} [filter[trigger]] - Filter by trigger type of automation + * @queryParam {string} [filter[state]] - Filter by state of automation + * @queryParam {number} [offset] - Skip the first n results. Default 0. + * @queryParam {number} [limit] - Limit the number of results. Default 50. + * @response 200 - Ok + * @responseContent {AutomationPage} 200.application/json + * @responseExample {AutomationPage} 200.application/json.AutomationPage + * @response 401 - Unauthorized + * @response 429 - Too many requests + */ +export default async (req, res) => { + try { + new PermissionChecker(req).validateHas(Permissions.values.automationRead) + + let offset = 0 + if (req.query.offset) { + offset = parseInt(req.query.offset, 10) + } + let limit = 50 + if (req.query.limit) { + limit = parseInt(req.query.limit, 10) + } + + const criteria: AutomationCriteria = { + type: req.query.filter?.type ? (req.query.filter.type as AutomationType) : undefined, + trigger: req.query.filter?.trigger + ? (req.query.filter?.trigger as AutomationTrigger) + : undefined, + state: req.query.filter?.state ? (req.query.filter.state as AutomationState) : undefined, + limit, + offset, + } + + const payload = await new AutomationService(req).findAndCountAll(criteria) + + await ApiResponseHandler.success(req, res, payload) + } catch (error) { + await ApiResponseHandler.error(req, res, error) + } +} diff --git a/backend/src/api/automation/automationUpdate.ts b/backend/src/api/automation/automationUpdate.ts new file mode 100644 index 0000000000..e1487a9b0f --- /dev/null +++ b/backend/src/api/automation/automationUpdate.ts @@ -0,0 +1,33 @@ +import PermissionChecker from '../../services/user/permissionChecker' +import Permissions from '../../security/permissions' +import AutomationService from '../../services/automationService' +import track from '../../segment/track' +import ApiResponseHandler from '../apiResponseHandler' + +/** + * PUT /tenant/{tenantId}/automation/{automationId} + * @summary Update an existing automation + * @tag Automations + * @security Bearer + * @description Updates an existing automation in the tenant. + * @pathParam {string} tenantId - Your workspace/tenant ID + * @pathParam {string} automationId - Automation ID that you want to update + * @bodyContent {AutomationUpdateInput} application/json + * @response 200 - Ok + * @responseContent {Automation} 200.application/json + * @responseExample {Automation} 200.application/json.Automation + * @response 401 - Unauthorized + * @response 429 - Too many requests + */ +export default async (req, res) => { + try { + new PermissionChecker(req).validateHas(Permissions.values.automationUpdate) + const payload = await new AutomationService(req).update(req.params.automationId, req.body.data) + + track('Automation Updated', { ...payload }, { ...req }) + + await ApiResponseHandler.success(req, res, payload) + } catch (error) { + await ApiResponseHandler.error(req, res, error) + } +} diff --git a/backend/src/api/automation/index.ts b/backend/src/api/automation/index.ts new file mode 100644 index 0000000000..2cc366ebce --- /dev/null +++ b/backend/src/api/automation/index.ts @@ -0,0 +1,11 @@ +export default (app) => { + app.post('/tenant/:tenantId/automation', require('./automationCreate').default) + app.put('/tenant/:tenantId/automation/:automationId', require('./automationUpdate').default) + app.delete('/tenant/:tenantId/automation/:automationId', require('./automationDestroy').default) + app.get( + '/tenant/:tenantId/automation/:automationId/executions', + require('./automationExecutionFind').default, + ) + app.get('/tenant/:tenantId/automation/:automationId', require('./automationFind').default) + app.get('/tenant/:tenantId/automation', require('./automationList').default) +} diff --git a/backend/src/api/components/core.yaml b/backend/src/api/components/core.yaml index a9b7f993c6..28cd753010 100644 --- a/backend/src/api/components/core.yaml +++ b/backend/src/api/components/core.yaml @@ -225,3 +225,76 @@ components: xml: name: Conversation + + # defines automation type enum + AutomationType: + description: Automation type + type: string + enum: ['webhook'] + + # defines automation state enum + AutomationState: + description: Automation state + type: string + enum: ['active', 'disabled'] + + # defines automation triggers + AutomationTrigger: + description: What will trigger an automation + type: string + enum: ['new_activity', 'new_member'] + + # defines automation execution state + AutomationExecutionState: + description: What was the state of the automation execution + type: string + enum: ['success', 'error'] + + # defines webhook automation settings + WebhookAutomationSettings: + description: Settings used by automation with type webhook + type: object + required: + - url + properties: + url: + description: URL to POST webhook data to + type: string + format: uri + + # defines new activity triggered automation settings + NewActivityAutomationSettings: + description: Settings used by automation that is triggered by new activities + type: object + required: + - types + - platforms + - keywords + - teamMemberActivities + properties: + types: + description: 'If activity type matches any of these we should trigger this automation' + type: array + items: + type: string + platforms: + description: 'If activity came from any of these platforms we should trigger this automation' + type: array + items: + type: string + keywords: + description: 'If activity content contains any of these keywords we should trigger this automation' + type: array + items: + type: string + teamMemberActivities: + description: 'If activity came from any of our team members - should we trigger automation or not?' + type: boolean + + # defines automation settings object + AutomationSettings: + description: Settings based on automation type and trigger - you need to provide union object of both automation type based settings and trigger based settings + type: object + anyOf: + - $ref: '#/components/schemas/WebhookAutomationSettings' + - $ref: '#/components/schemas/NewActivityAutomationSettings' diff --git a/backend/src/api/components/examples.yaml b/backend/src/api/components/examples.yaml index 6dc9ded312..9073c55185 100644 --- a/backend/src/api/components/examples.yaml +++ b/backend/src/api/components/examples.yaml @@ -426,3 +426,44 @@ components: rows: - $ref: '#/components/examples/Tag' - $ref: '#/components/examples/Tag2' + + Automation: + value: + id: b3297f3b-6924-4e92-80e7-ef2e0d87a120 + type: 'webhook' + tenantId: a3297f3b-6924-4e92-80e7-ef2e0d87a120 + trigger: 'new_activity' + settings: + url: 'https://webhook.url/new_activities' + createdAt: '2022-03-29T09:22:31.989Z' + + AutomationPage: + value: + count: 1 + offset: 0 + limit: 10 + rows: + - id: b3297f3b-6924-4e92-80e7-ef2e0d87a120 + type: 'webhook' + tenantId: a3297f3b-6924-4e92-80e7-ef2e0d87a120 + trigger: 'new_activity' + settings: + url: 'https://webhook.url/new_activities' + createdAt: '2022-03-29T09:22:31.989Z' + + AutomationExecutionPage: + value: + count: 1 + offset: 0 + limit: 10 + rows: + - id: 'b3297f3b-6924-4e92-80e7-ef2e0d87a120' + automationId: 'a3297f3b-6924-4e92-80e7-ef2e0d87a120' + state: success + executedAt: '2022-03-29T09:22:31.989Z' + eventId: 'a3297f3b-6924-4e92-80e7-ef2e0d87a121' + payload: + - id: 'a3297f3b-6924-4e92-80e7-ef2e0d87a121' + type: 'comment' + timestamp: '2022-03-29T09:22:31.989Z' + platform: 'twitter' diff --git a/backend/src/api/components/inputs.yaml b/backend/src/api/components/inputs.yaml index 43976cc271..0bb4026094 100644 --- a/backend/src/api/components/inputs.yaml +++ b/backend/src/api/components/inputs.yaml @@ -38,3 +38,45 @@ components: properties: communityMember: $ref: '#/components/schemas/CommunityMemberNoId' + + AutomationCreateInput: + type: object + description: >- + Data to create a new automation. + required: + - data + properties: + data: + type: object + required: + - type + - trigger + - settings + properties: + type: + $ref: '#/components/schemas/AutomationType' + trigger: + $ref: '#/components/schemas/AutomationTrigger' + settings: + $ref: '#/components/schemas/AutomationSettings' + + AutomationUpdateInput: + type: object + description: >- + Data to update an existing automation. + required: + - data + properties: + data: + type: object + required: + - trigger + - settings + - state + properties: + trigger: + $ref: '#/components/schemas/AutomationTrigger' + settings: + $ref: '#/components/schemas/AutomationSettings' + state: + $ref: '#/components/schemas/AutomationState' diff --git a/backend/src/api/components/responses.yaml b/backend/src/api/components/responses.yaml index 22d05629f2..77b861b8ab 100644 --- a/backend/src/api/components/responses.yaml +++ b/backend/src/api/components/responses.yaml @@ -101,3 +101,126 @@ components: type: array items: $ref: '#/components/schemas/Conversation' + + Automation: + type: object + required: + - id + - type + - tenantId + - trigger + - settings + - state + - createdAt + properties: + id: + description: Automation unique ID + type: string + format: uuid + type: + $ref: '#/components/schemas/AutomationType' + tenantId: + description: Automation tenant unique ID + type: string + format: uuid + trigger: + $ref: '#/components/schemas/AutomationTrigger' + settings: + $ref: '#/components/schemas/AutomationSettings' + state: + $ref: '#/components/schemas/AutomationState' + createdAt: + description: When was automation created + type: string + format: date-time + lastExecutionAt: + description: When was automation last executed + type: string + format: date-time + lastExecutionState: + description: State of the last automation execution + $ref: '#/components/schemas/AutomationExecutionState' + lastExecutionError: + description: Error information if last automation execution failed + type: object + + AutomationPage: + type: object + required: + - rows + - count + - offset + - limit + properties: + rows: + description: Array of automations that were fetched + type: array + items: + $ref: '#/components/schemas/Automation' + count: + description: How many total automations there are + type: integer + offset: + description: What offset was used when preparing this response + type: integer + limit: + description: What limit was used when preparing this response + type: integer + + AutomationExecution: + type: object + required: + - id + - automationId + - state + - executedAt + - eventId + - payload + properties: + id: + description: Automation execution unique ID + type: string + format: uuid + automationId: + description: Automation unique ID + type: string + format: uuid + state: + description: Automation execution state + $ref: '#/components/schemas/AutomationExecutionState' + error: + description: If execution was not successful this object will contain error information + type: object + executedAt: + description: Automation execution timestamp + type: string + format: date-time + eventId: + description: Unique ID of the event that triggered this automation execution. + type: string + payload: + description: Payload that was sent when this execution was processed + type: object + + AutomationExecutionPage: + type: object + required: + - rows + - count + - offset + - limit + properties: + rows: + description: Automation Execution List + type: array + items: + $ref: '#/components/schemas/AutomationExecution' + count: + description: How many items are there in total + type: integer + offset: + description: What offset was used when preparing this response + type: integer + limit: + description: What limit was used when preparing this response + type: integer diff --git a/backend/src/api/index.ts b/backend/src/api/index.ts index 2539d6010a..41c294d74f 100644 --- a/backend/src/api/index.ts +++ b/backend/src/api/index.ts @@ -84,6 +84,7 @@ require('./integration').default(routes) require('./microservice').default(routes) require('./conversation').default(routes) require('./eagleEyeContent').default(routes) +require('./automation').default(routes) // Loads the Tenant if the :tenantId param is passed routes.param('tenantId', tenantMiddleware) diff --git a/backend/src/database/migrations/2022-09-01-automations.ts b/backend/src/database/migrations/2022-09-01-automations.ts new file mode 100644 index 0000000000..62d6783686 --- /dev/null +++ b/backend/src/database/migrations/2022-09-01-automations.ts @@ -0,0 +1,218 @@ +import { QueryInterface } from 'sequelize/types' + +export const up = async (queryInterface: QueryInterface, sequelize) => { + const transaction = await queryInterface.sequelize.transaction() + + try { + await queryInterface.createTable( + 'automations', + { + id: { + allowNull: false, + primaryKey: true, + type: sequelize.UUID, + }, + type: { + allowNull: false, + type: sequelize.STRING(80), + validate: { + notEmpty: true, + }, + }, + tenantId: { + allowNull: false, + type: sequelize.UUID, + }, + trigger: { + allowNull: false, + type: sequelize.STRING(80), + validate: { + notEmpty: true, + }, + }, + settings: { + allowNull: false, + type: sequelize.JSONB, + }, + state: { + allowNull: false, + type: sequelize.STRING(80), + validate: { + notEmpty: true, + }, + }, + + // metadata + createdAt: { + allowNull: false, + type: sequelize.DATE, + }, + + createdById: { + allowNull: false, + type: sequelize.UUID, + }, + + updatedAt: { + allowNull: false, + type: sequelize.DATE, + }, + updatedById: { + type: sequelize.UUID, + }, + }, + { transaction }, + ) + await queryInterface.addConstraint('automations', { + type: 'foreign key', + fields: ['tenantId'], + name: 'automations_tenantId_fkey', + references: { + table: 'tenants', + field: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'NO ACTION', + transaction, + }) + await queryInterface.addConstraint('automations', { + type: 'foreign key', + fields: ['createdById'], + name: 'automations_createdById_fkey', + references: { + table: 'users', + field: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'NO ACTION', + transaction, + }) + await queryInterface.addConstraint('automations', { + type: 'foreign key', + fields: ['updatedById'], + name: 'automations_updatedById_fkey', + references: { + table: 'users', + field: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'NO ACTION', + transaction, + }) + + await queryInterface.sequelize.query( + 'create index "automations_tenantId" on automations ("tenantId")', + { transaction }, + ) + await queryInterface.sequelize.query( + 'create index "automations_type_tenantId_trigger_state" on automations (type, "tenantId", trigger, state)', + { transaction }, + ) + + await queryInterface.createTable( + 'automationExecutions', + { + id: { + allowNull: false, + primaryKey: true, + type: sequelize.UUID, + }, + automationId: { + allowNull: false, + type: sequelize.UUID, + }, + type: { + allowNull: false, + type: sequelize.STRING(80), + validate: { + notEmpty: true, + }, + }, + tenantId: { + allowNull: false, + type: sequelize.UUID, + }, + trigger: { + allowNull: false, + type: sequelize.STRING(80), + validate: { + notEmpty: true, + }, + }, + state: { + allowNull: false, + type: sequelize.STRING(80), + validate: { + notEmpty: true, + }, + }, + error: { + allowNull: true, + type: sequelize.JSON, + }, + executedAt: { + type: sequelize.DATE, + allowNull: false, + }, + eventId: { + type: sequelize.STRING(255), + allowNull: false, + validate: { + notEmpty: true, + }, + }, + payload: { + type: sequelize.JSON, + allowNull: false, + }, + }, + { transaction }, + ) + + await queryInterface.sequelize.query( + 'create index "automationExecutions_automationId" on "automationExecutions" ("automationId")', + { transaction }, + ) + await queryInterface.addConstraint('automationExecutions', { + type: 'foreign key', + fields: ['tenantId'], + name: 'automationExecutions_tenantId_fkey', + references: { + table: 'tenants', + field: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'NO ACTION', + transaction, + }) + await queryInterface.addConstraint('automationExecutions', { + type: 'foreign key', + fields: ['automationId'], + name: 'automationExecutions_automationId_fkey', + references: { + table: 'automations', + field: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'NO ACTION', + transaction, + }) + + await transaction.commit() + } catch (error) { + await transaction.rollback() + throw error + } +} + +export const down = async (queryInterface: QueryInterface) => { + const transaction = await queryInterface.sequelize.transaction() + try { + await queryInterface.dropTable('automationExecutions', { transaction }) + await queryInterface.dropTable('automations', { transaction }) + await transaction.commit() + } catch (error) { + await transaction.rollback() + throw error + } +} diff --git a/backend/src/database/models/automation.ts b/backend/src/database/models/automation.ts new file mode 100644 index 0000000000..9c4b640de6 --- /dev/null +++ b/backend/src/database/models/automation.ts @@ -0,0 +1,71 @@ +import { DataTypes } from 'sequelize' + +export default (sequelize) => { + const automation = sequelize.define( + 'automation', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false, + }, + type: { + type: DataTypes.STRING(80), + allowNull: false, + validate: { + notEmpty: true, + }, + }, + tenantId: { + type: DataTypes.UUID, + allowNull: false, + }, + trigger: { + type: DataTypes.STRING(80), + allowNull: false, + validate: { + notEmpty: true, + }, + }, + settings: { + type: DataTypes.JSONB, + allowNull: false, + }, + state: { + type: DataTypes.STRING(80), + allowNull: false, + validate: { + notEmpty: true, + }, + }, + }, + { + indexes: [ + { + fields: ['type', 'tenantId', 'trigger', 'state'], + }, + ], + timestamps: true, + }, + ) + + automation.associate = (models) => { + models.automation.belongsTo(models.tenant, { + as: 'tenant', + foreignKey: { + allowNull: false, + }, + }) + + models.automation.belongsTo(models.user, { + as: 'createdBy', + }) + + models.automation.belongsTo(models.user, { + as: 'updatedBy', + }) + } + + return automation +} diff --git a/backend/src/database/models/automationExecution.ts b/backend/src/database/models/automationExecution.ts new file mode 100644 index 0000000000..72b59291ce --- /dev/null +++ b/backend/src/database/models/automationExecution.ts @@ -0,0 +1,90 @@ +import { DataTypes } from 'sequelize' + +export default (sequelize) => { + const automationExecution = sequelize.define( + 'automationExecution', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false, + }, + automationId: { + type: DataTypes.UUID, + allowNull: false, + }, + type: { + type: DataTypes.STRING(80), + allowNull: false, + validate: { + notEmpty: true, + }, + }, + tenantId: { + type: DataTypes.UUID, + allowNull: false, + }, + trigger: { + type: DataTypes.STRING(80), + allowNull: false, + validate: { + notEmpty: true, + }, + }, + state: { + type: DataTypes.STRING(80), + allowNull: false, + validate: { + notEmpty: true, + }, + }, + error: { + type: DataTypes.JSON, + allowNull: true, + }, + executedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + eventId: { + type: DataTypes.STRING(255), + allowNull: false, + validate: { + notEmpty: true, + }, + }, + payload: { + type: DataTypes.JSON, + allowNull: false, + }, + }, + { + indexes: [ + { + fields: ['automationId'], + }, + ], + timestamps: false, + paranoid: false, + }, + ) + + automationExecution.associate = (models) => { + models.automationExecution.belongsTo(models.tenant, { + as: 'tenant', + foreignKey: { + allowNull: false, + }, + }) + + models.automationExecution.belongsTo(models.automation, { + as: 'automation', + foreignKey: { + allowNull: false, + }, + }) + } + + return automationExecution +} diff --git a/backend/src/database/models/index.ts b/backend/src/database/models/index.ts index c307b64a1c..48d1e6778e 100644 --- a/backend/src/database/models/index.ts +++ b/backend/src/database/models/index.ts @@ -57,6 +57,8 @@ function models() { require('./conversation').default, require('./conversationSettings').default, require('./eagleEyeContent').default, + require('./automation').default, + require('./automationExecution').default, ] for (const notInitmodel of modelClasses) { diff --git a/backend/src/database/repositories/automationExecutionRepository.ts b/backend/src/database/repositories/automationExecutionRepository.ts new file mode 100644 index 0000000000..a7c59b0fe8 --- /dev/null +++ b/backend/src/database/repositories/automationExecutionRepository.ts @@ -0,0 +1,119 @@ +/* eslint-disable class-methods-use-this */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { QueryTypes } from 'sequelize' +import { IRepositoryOptions } from './IRepositoryOptions' +import { DbAutomationExecutionInsertData } from './types/automationTypes' +import { AutomationExecution, AutomationExecutionCriteria } from '../../types/automationTypes' +import { PageData } from '../../types/common' +import { RepositoryBase } from './repositoryBase' + +export default class AutomationExecutionRepository extends RepositoryBase< + AutomationExecution, + string, + DbAutomationExecutionInsertData, + unknown, + AutomationExecutionCriteria +> { + public constructor(options: IRepositoryOptions) { + super(options, false) + } + + override async create(data: DbAutomationExecutionInsertData): Promise { + const transaction = this.transaction + + return this.database.automationExecution.create( + { + automationId: data.automationId, + type: data.type, + tenantId: data.tenantId, + trigger: data.trigger, + state: data.state, + error: data.error, + executedAt: data.executedAt, + eventId: data.eventId, + payload: data.payload, + }, + { transaction }, + ) + } + + override async findAndCountAll( + criteria: AutomationExecutionCriteria, + ): Promise> { + // get current tenant that was used to make a request + const currentTenant = this.currentTenant + + // get plain sequelize object to use with a raw query + const seq = this.seq + + // construct a query with pagination + const query = ` + select id, + "automationId", + state, + error, + "executedAt", + "eventId", + payload, + count(*) over () as "paginatedItemsCount" + from "automationExecutions" + where "tenantId" = :tenantId + and "automationId" = :automationId + limit ${criteria.limit} offset ${criteria.offset} + ` + + const results = await seq.query(query, { + replacements: { + tenantId: currentTenant.id, + automationId: criteria.automationId, + }, + type: QueryTypes.SELECT, + }) + + if (results.length === 0) { + return { + rows: [], + count: 0, + offset: criteria.offset, + limit: criteria.limit, + } + } + + const count = parseInt((results[0] as any).paginatedItemsCount, 10) + const rows: AutomationExecution[] = results.map((r) => { + const row = r as any + return { + id: row.id, + automationId: row.automationId, + executedAt: row.executedAt, + eventId: row.eventId, + payload: row.payload, + error: row.error, + state: row.state, + } + }) + + return { + rows, + count, + offset: criteria.offset, + limit: criteria.limit, + } + } + + override async update(id: string, data: unknown): Promise { + throw new Error('Method not implemented.') + } + + override async destroy(id: string): Promise { + throw new Error('Method not implemented.') + } + + override async destroyAll(ids: string[]): Promise { + throw new Error('Method not implemented.') + } + + override async findById(id: string): Promise { + throw new Error('Method not implemented.') + } +} diff --git a/backend/src/database/repositories/automationRepository.ts b/backend/src/database/repositories/automationRepository.ts new file mode 100644 index 0000000000..3eb54e493a --- /dev/null +++ b/backend/src/database/repositories/automationRepository.ts @@ -0,0 +1,231 @@ +import Sequelize, { QueryTypes } from 'sequelize' +import AuditLogRepository from './auditLogRepository' +import { IRepositoryOptions } from './IRepositoryOptions' +import Error404 from '../../errors/Error404' +import { AutomationCriteria, AutomationData } from '../../types/automationTypes' +import { DbAutomationInsertData, DbAutomationUpdateData } from './types/automationTypes' +import { PageData } from '../../types/common' +import { RepositoryBase } from './repositoryBase' + +const { Op } = Sequelize + +export default class AutomationRepository extends RepositoryBase< + AutomationData, + string, + DbAutomationInsertData, + DbAutomationUpdateData, + AutomationCriteria +> { + public constructor(options: IRepositoryOptions) { + super(options, true) + } + + override async create(data: DbAutomationInsertData): Promise { + const currentUser = this.currentUser + + const tenant = this.currentTenant + + const transaction = this.transaction + + const record = await this.database.automation.create( + { + type: data.type, + trigger: data.trigger, + settings: data.settings, + state: data.state, + tenantId: tenant.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { + transaction, + }, + ) + + await this.createAuditLog('automation', AuditLogRepository.CREATE, record, data) + + return this.findById(record.id) + } + + override async update(id, data: DbAutomationUpdateData): Promise { + const currentUser = this.currentUser + + const currentTenant = this.currentTenant + + const transaction = this.transaction + + let record = await this.database.automation.findOne({ + where: { + id, + tenantId: currentTenant.id, + }, + transaction, + }) + + if (!record) { + throw new Error404() + } + + record = await record.update( + { + trigger: data.trigger, + settings: data.settings, + state: data.state, + updatedById: currentUser.id, + }, + { + transaction, + }, + ) + + await this.createAuditLog('automation', AuditLogRepository.UPDATE, record, data) + + return this.findById(record.id) + } + + override async destroyAll(ids: string[]): Promise { + const transaction = this.transaction + + const currentTenant = this.currentTenant + + const records = await this.database.automation.findAll({ + where: { + id: { + [Op.in]: ids, + }, + tenantId: currentTenant.id, + }, + transaction, + }) + + if (ids.some((id) => records.find((r) => r.id === id) === undefined)) { + throw new Error404() + } + + await Promise.all( + records.flatMap((r) => [ + r.destroy({ transaction }), + this.createAuditLog('automation', AuditLogRepository.DELETE, r, r), + ]), + ) + } + + override async findById(id: string): Promise { + const results = await this.findAndCountAll({ + id, + offset: 0, + limit: 1, + }) + + if (results.count === 1) { + return results.rows[0] + } + + if (results.count === 0) { + throw new Error404() + } + + throw new Error('More than one row returned when fetching by automation unique ID!') + } + + override async findAndCountAll(criteria: AutomationCriteria): Promise> { + // get current tenant that was used to make a request + const currentTenant = this.currentTenant + + // we need transaction if there is one set because some records were perhaps created/updated in the same transaction + const transaction = this.transaction + + // get plain sequelize object to use with a raw query + const seq = this.seq + + // build a where condition based on tenant and other criteria passed as parameter + const conditions = ['a."tenantId" = :tenantId'] + const parameters: any = { + tenantId: currentTenant.id, + } + + if (criteria.id) { + conditions.push('a.id = :id') + parameters.id = criteria.id + } + + if (criteria.state) { + conditions.push('a.state = :state') + parameters.state = criteria.state + } + + if (criteria.type) { + conditions.push('a.type = :type') + parameters.type = criteria.type + } + + if (criteria.trigger) { + conditions.push('a.trigger = :trigger') + parameters.trigger = criteria.trigger + } + + const conditionsString = conditions.join(' and ') + + const query = ` + -- common table expression (CTE) to prepare the last execution information for each automationId + with latest_executions as (select distinct on ("automationId") "automationId", "executedAt", state, error + from "automationExecutions" + order by "automationId", "executedAt" desc) + select a.id, + a.type, + a."tenantId", + a.trigger, + a.settings, + a.state, + a."createdAt", + le."executedAt" as "lastExecutionAt", + le.state as "lastExecutionState", + le.error as "lastExecutionError", + count(*) over () as "paginatedItemsCount" + from automations a + left join latest_executions le on a.id = le."automationId" + where ${conditionsString} + ${this.getPaginationString(criteria)} + ` + // fetch all automations for a tenant + // and include the latest execution data if available + const results = await seq.query(query, { + replacements: parameters, + type: QueryTypes.SELECT, + transaction, + }) + + if (results.length === 0) { + return { + rows: [], + count: 0, + offset: criteria.offset, + limit: criteria.limit, + } + } + + const count = parseInt((results[0] as any).paginatedItemsCount, 10) + const rows: AutomationData[] = results.map((r) => { + const row = r as any + return { + id: row.id, + type: row.type, + tenantId: row.tenantId, + trigger: row.trigger, + settings: row.settings, + state: row.state, + createdAt: row.createdAt, + lastExecutionAt: row.lastExecutionAt, + lastExecutionState: row.lastExecutionState, + lastExecutionError: row.lastExecutionError, + } + }) + + return { + rows, + count, + offset: criteria.offset, + limit: criteria.limit, + } + } +} diff --git a/backend/src/database/repositories/repositoryBase.ts b/backend/src/database/repositories/repositoryBase.ts new file mode 100644 index 0000000000..ffc2c9f499 --- /dev/null +++ b/backend/src/database/repositories/repositoryBase.ts @@ -0,0 +1,117 @@ +/* eslint-disable class-methods-use-this,@typescript-eslint/no-unused-vars */ +import { Sequelize } from 'sequelize' +import { IRepositoryOptions } from './IRepositoryOptions' +import { PageData, SearchCriteria } from '../../types/common' +import AuditLogRepository from './auditLogRepository' +import SequelizeRepository from './sequelizeRepository' + +export abstract class RepositoryBase< + TData, + TId, + TCreate, + TUpdate, + TCriteria extends SearchCriteria, +> { + protected constructor( + public readonly options: IRepositoryOptions, + public readonly log: boolean = false, + ) {} + + protected get currentUser(): any { + return SequelizeRepository.getCurrentUser(this.options) + } + + protected get currentTenant(): any { + return SequelizeRepository.getCurrentTenant(this.options) + } + + protected get transaction(): any { + return SequelizeRepository.getTransaction(this.options) + } + + protected get seq(): Sequelize { + return SequelizeRepository.getSequelize(this.options) + } + + protected get database(): any { + return this.options.database + } + + abstract create(data: TCreate): Promise + + abstract update(id: TId, data: TUpdate): Promise + + async destroy(id: TId): Promise { + return this.destroyAll([id]) + } + + abstract destroyAll(ids: TId[]): Promise + + abstract findById(id: TId): Promise + + abstract findAndCountAll(criteria: TCriteria): Promise> + + async findAll(criteria: TCriteria): Promise { + const copy = { ...criteria } + + // let's initially load just the first row in the db to see how many elements there are in total + copy.offset = undefined + copy.limit = undefined + const page = await this.findAndCountAll(criteria) + + return page.rows + } + + protected async createAuditLog( + entity: string, + action: string, + record: any, + data: any, + ): Promise { + if (this.log) { + let values = {} + + if (data) { + values = { + ...record.get({ plain: true }), + } + } + + await AuditLogRepository.log( + { + entityName: entity, + entityId: record.id, + action, + values, + }, + this.options, + ) + } + } + + protected async populateRelationsForRows(rows: any[]): Promise { + return Promise.all(rows.map((r) => this.populateRelations(r))) + } + + protected async populateRelations(record: any): Promise { + if (!record) return record + + return record.get({ plain: true }) + } + + protected isPaginationValid(criteria: SearchCriteria): boolean { + if (criteria.limit && criteria.offset) { + return criteria.limit > 0 && criteria.offset >= 0 + } + + return false + } + + protected getPaginationString(criteria: SearchCriteria): string { + if (this.isPaginationValid(criteria)) { + return `limit ${criteria.limit} offset ${criteria.offset}` + } + + return '' + } +} diff --git a/backend/src/database/repositories/sequelizeRepository.ts b/backend/src/database/repositories/sequelizeRepository.ts index 93d5d0dae9..8bbf4382ec 100644 --- a/backend/src/database/repositories/sequelizeRepository.ts +++ b/backend/src/database/repositories/sequelizeRepository.ts @@ -1,5 +1,5 @@ import lodash from 'lodash' -import { UniqueConstraintError } from 'sequelize' +import { Sequelize, UniqueConstraintError } from 'sequelize' import Error400 from '../../errors/Error400' import { databaseInit } from '../databaseConnection' import { searchEngineInit } from '../../search-engine/searchEngineConnection' @@ -82,4 +82,8 @@ export default class SequelizeRepository { const fieldName = lodash.get(error, 'errors[0].path') throw new Error400(language, `entities.${entityName}.errors.unique.${fieldName}`) } + + static getSequelize(options: IRepositoryOptions): Sequelize { + return options.database.sequelize as Sequelize + } } diff --git a/backend/src/database/repositories/types/automationTypes.ts b/backend/src/database/repositories/types/automationTypes.ts new file mode 100644 index 0000000000..090f68b5d1 --- /dev/null +++ b/backend/src/database/repositories/types/automationTypes.ts @@ -0,0 +1,32 @@ +import { + AutomationExecutionState, + AutomationSettings, + AutomationState, + AutomationTrigger, + AutomationType, +} from '../../../types/automationTypes' + +export interface DbAutomationInsertData { + type: AutomationType + trigger: AutomationTrigger + settings: AutomationSettings + state: AutomationState +} + +export interface DbAutomationUpdateData { + trigger: AutomationTrigger + settings: AutomationSettings + state: AutomationState +} + +export interface DbAutomationExecutionInsertData { + automationId: string + type: AutomationType + tenantId: string + trigger: AutomationTrigger + state: AutomationExecutionState + error: any | null + executedAt: Date + eventId: string + payload: any +} diff --git a/backend/src/database/sequelize-cli-config.ts b/backend/src/database/sequelize-cli-config.ts index 2b4c8b357f..ade45bb1e0 100644 --- a/backend/src/database/sequelize-cli-config.ts +++ b/backend/src/database/sequelize-cli-config.ts @@ -6,6 +6,7 @@ const dbEnvVars = { database: process.env.DATABASE_DATABASE, host: process.env.DATABASE_HOST_WRITE, dialect: process.env.DATABASE_DIALECT, + logging: true, } let currentEnvironmentVariables = {} diff --git a/backend/src/security/permissions.ts b/backend/src/security/permissions.ts index 4d607f09a9..b71169a585 100644 --- a/backend/src/security/permissions.ts +++ b/backend/src/security/permissions.ts @@ -142,6 +142,26 @@ class Permissions { allowedRoles: [roles.admin, roles.readonly], allowedPlans: [plans.free, plans.beta, plans.premium, plans.enterprise], }, + automationCreate: { + id: 'automationCreate', + allowedRoles: [roles.admin], + allowedPlans: [plans.free, plans.beta, plans.premium, plans.enterprise], + }, + automationUpdate: { + id: 'automationUpdate', + allowedRoles: [roles.admin], + allowedPlans: [plans.free, plans.beta, plans.premium, plans.enterprise], + }, + automationDestroy: { + id: 'automationDestroy', + allowedRoles: [roles.admin], + allowedPlans: [plans.free, plans.beta, plans.premium, plans.enterprise], + }, + automationRead: { + id: 'automationRead', + allowedRoles: [roles.admin, roles.readonly], + allowedPlans: [plans.free, plans.beta, plans.premium, plans.enterprise], + }, tagImport: { id: 'tagImport', allowedRoles: [roles.admin], diff --git a/backend/src/serverless/dbOperations/serverless.yml b/backend/src/serverless/dbOperations/serverless.yml index fe39189dc8..aaedde9298 100644 --- a/backend/src/serverless/dbOperations/serverless.yml +++ b/backend/src/serverless/dbOperations/serverless.yml @@ -66,6 +66,7 @@ constructs: NODE_ENV: ${env:NODE_ENV} EDITION: ${env:EDITION} SEGMENT_WRITE_KEY: ${env:SEGMENT_WRITE_KEY} + NODE_MICROSERVICES_SQS_URL: ${env:NODE_MICROSERVICES_SQS_URL} DATABASE_USERNAME: ${env:DATABASE_USERNAME} DATABASE_DIALECT: 'postgres' DATABASE_PASSWORD: ${env:DATABASE_PASSWORD} @@ -84,6 +85,7 @@ functions: NODE_ENV: ${env:NODE_ENV} EDITION: ${env:EDITION} SEGMENT_WRITE_KEY: ${env:SEGMENT_WRITE_KEY} + NODE_MICROSERVICES_SQS_URL: ${env:NODE_MICROSERVICES_SQS_URL} DATABASE_USERNAME: ${env:DATABASE_USERNAME} DATABASE_DIALECT: 'postgres' DATABASE_PASSWORD: ${env:DATABASE_PASSWORD} diff --git a/backend/src/serverless/integrations/serverless.yml b/backend/src/serverless/integrations/serverless.yml index e0ad1cfe8e..ffb29d3fff 100644 --- a/backend/src/serverless/integrations/serverless.yml +++ b/backend/src/serverless/integrations/serverless.yml @@ -89,6 +89,7 @@ functions: EDITION: ${env:EDITION} SEGMENT_WRITE_KEY: ${env:SEGMENT_WRITE_KEY} INTEGRATIONS_SQS_URL: ${env:INTEGRATIONS_SQS_URL} + NODE_MICROSERVICES_SQS_URL: ${env:NODE_MICROSERVICES_SQS_URL} DATABASE_USERNAME: ${env:DATABASE_USERNAME} DATABASE_DIALECT: ${env:DATABASE_DIALECT} DATABASE_PASSWORD: ${env:DATABASE_PASSWORD} @@ -109,6 +110,7 @@ functions: EDITION: ${env:EDITION} SEGMENT_WRITE_KEY: ${env:SEGMENT_WRITE_KEY} INTEGRATIONS_SQS_URL: ${env:INTEGRATIONS_SQS_URL} + NODE_MICROSERVICES_SQS_URL: ${env:NODE_MICROSERVICES_SQS_URL} DATABASE_USERNAME: ${env:DATABASE_USERNAME} DATABASE_DIALECT: ${env:DATABASE_DIALECT} DATABASE_PASSWORD: ${env:DATABASE_PASSWORD} @@ -130,6 +132,7 @@ functions: EDITION: ${env:EDITION} SEGMENT_WRITE_KEY: ${env:SEGMENT_WRITE_KEY} INTEGRATIONS_SQS_URL: ${env:INTEGRATIONS_SQS_URL} + NODE_MICROSERVICES_SQS_URL: ${env:NODE_MICROSERVICES_SQS_URL} DATABASE_USERNAME: ${env:DATABASE_USERNAME} DATABASE_DIALECT: ${env:DATABASE_DIALECT} DATABASE_PASSWORD: ${env:DATABASE_PASSWORD} @@ -151,6 +154,7 @@ functions: EDITION: ${env:EDITION} SEGMENT_WRITE_KEY: ${env:SEGMENT_WRITE_KEY} INTEGRATIONS_SQS_URL: ${env:INTEGRATIONS_SQS_URL} + NODE_MICROSERVICES_SQS_URL: ${env:NODE_MICROSERVICES_SQS_URL} LOCALSTACK_HOSTNAME: ${env:LOCALSTACK_HOSTNAME} LOCALSTACK_PORT: ${env:LOCALSTACK_PORT} DATABASE_USERNAME: ${env:DATABASE_USERNAME} @@ -174,6 +178,7 @@ functions: EDITION: ${env:EDITION} SEGMENT_WRITE_KEY: ${env:SEGMENT_WRITE_KEY} INTEGRATIONS_SQS_URL: ${env:INTEGRATIONS_SQS_URL} + NODE_MICROSERVICES_SQS_URL: ${env:NODE_MICROSERVICES_SQS_URL} LOCALSTACK_HOSTNAME: ${env:LOCALSTACK_HOSTNAME} LOCALSTACK_PORT: ${env:LOCALSTACK_PORT} DATABASE_USERNAME: ${env:DATABASE_USERNAME} @@ -217,6 +222,7 @@ functions: EDITION: ${env:EDITION} SEGMENT_WRITE_KEY: ${env:SEGMENT_WRITE_KEY} INTEGRATIONS_SQS_URL: ${env:INTEGRATIONS_SQS_URL} + NODE_MICROSERVICES_SQS_URL: ${env:NODE_MICROSERVICES_SQS_URL} SUPERFACE_SANDBOX_TIMEOUT: 1000 DATABASE_USERNAME: ${env:DATABASE_USERNAME} DATABASE_DIALECT: ${env:DATABASE_DIALECT} diff --git a/backend/src/serverless/microservices/nodejs/analytics/workers/workerFactory.ts b/backend/src/serverless/microservices/nodejs/analytics/workers/workerFactory.ts deleted file mode 100644 index 4896e4ed89..0000000000 --- a/backend/src/serverless/microservices/nodejs/analytics/workers/workerFactory.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { weeklyAnalyticsEmailsWorker } from './weeklyAnalyticsEmailsWorker' -import { AnalyticsEmailsOutput, NodeMicroserviceMessage } from '../../messageTypes' - -/** - * Worker factory for spawning different microservices - * according to event.service - * @param event - * @returns worker function promise - */ - -async function workerFactory(event: NodeMicroserviceMessage): Promise { - console.log('Starting main worker with event, ', event) - - const { service, tenant } = event - - switch (service.toLowerCase()) { - case 'weekly-analytics-emails': - return weeklyAnalyticsEmailsWorker(tenant) - default: - throw new Error(`Invalid microservice ${service}`) - } -} - -export default workerFactory diff --git a/backend/src/serverless/microservices/nodejs/automation/workers/__tests__/newActivityWorker.test.ts b/backend/src/serverless/microservices/nodejs/automation/workers/__tests__/newActivityWorker.test.ts new file mode 100644 index 0000000000..88ab144a7a --- /dev/null +++ b/backend/src/serverless/microservices/nodejs/automation/workers/__tests__/newActivityWorker.test.ts @@ -0,0 +1,148 @@ +import { + AutomationData, + AutomationState, + AutomationTrigger, + AutomationType, + NewActivitySettings, +} from '../../../../../../types/automationTypes' +import { PlatformType } from '../../../../../../utils/platforms' +import { shouldProcessActivity } from '../newActivityWorker' + +function createAutomationData(settings: NewActivitySettings): AutomationData { + return { + id: '123', + state: AutomationState.ACTIVE, + trigger: AutomationTrigger.NEW_ACTIVITY, + settings, + type: AutomationType.WEBHOOK, + createdAt: new Date().toISOString(), + tenantId: '321', + lastExecutionAt: null, + lastExecutionError: null, + lastExecutionState: null, + } +} + +describe('New Activity Automation Worker tests', () => { + it('Should process an activity that matches settings', () => { + const automation = createAutomationData({ + platforms: [PlatformType.DEVTO], + types: ['comment'], + keywords: ['Crowd.dev'], + teamMemberActivities: false, + }) + + const activity = { + id: '1234', + type: 'comment', + platform: PlatformType.DEVTO, + crowdInfo: { + body: 'Crowd.dev is awesome!', + }, + } + + expect(shouldProcessActivity(activity, automation)).toBeTruthy() + }) + + it('Shouldn process an activity that belongs to a team member', () => { + const automation = createAutomationData({ + platforms: [PlatformType.DEVTO], + types: ['comment'], + keywords: ['Crowd.dev'], + teamMemberActivities: true, + }) + + const activity = { + id: '1234', + type: 'comment', + platform: PlatformType.DEVTO, + crowdInfo: { + teamMember: true, + body: 'Crowd.dev all awesome!', + }, + } + + expect(shouldProcessActivity(activity, automation)).toBeTruthy() + }) + + it("Shouldn't process an activity which platform does not match", () => { + const automation = createAutomationData({ + platforms: [PlatformType.DEVTO], + types: ['comment'], + keywords: ['Crowd.dev'], + teamMemberActivities: false, + }) + + const activity = { + id: '1234', + type: 'comment', + platform: PlatformType.DISCORD, + crowdInfo: { + body: 'Crowd.dev is awesome!', + }, + } + + expect(shouldProcessActivity(activity, automation)).toBeFalsy() + }) + + it("Shouldn't process an activity which type does not match", () => { + const automation = createAutomationData({ + platforms: [PlatformType.DEVTO], + types: ['comment'], + keywords: ['Crowd.dev'], + teamMemberActivities: false, + }) + + const activity = { + id: '1234', + type: 'follow', + platform: PlatformType.DEVTO, + crowdInfo: { + body: 'Crowd.dev is awesome!', + }, + } + + expect(shouldProcessActivity(activity, automation)).toBeFalsy() + }) + + it("Shouldn't process an activity which keyword does not match", () => { + const automation = createAutomationData({ + platforms: [PlatformType.DEVTO], + types: ['comment'], + keywords: ['Crowd.dev'], + teamMemberActivities: false, + }) + + const activity = { + id: '1234', + type: 'comment', + platform: PlatformType.DEVTO, + crowdInfo: { + body: 'We are all awesome!', + }, + } + + expect(shouldProcessActivity(activity, automation)).toBeFalsy() + }) + + it("Shouldn't process an activity that belongs to a team member", () => { + const automation = createAutomationData({ + platforms: [PlatformType.DEVTO], + types: ['comment'], + keywords: ['Crowd.dev'], + teamMemberActivities: false, + }) + + const activity = { + id: '1234', + type: 'comment', + platform: PlatformType.DEVTO, + crowdInfo: { + teamMember: true, + body: 'Crowd.dev all awesome!', + }, + } + + expect(shouldProcessActivity(activity, automation)).toBeFalsy() + }) +}) diff --git a/backend/src/serverless/microservices/nodejs/automation/workers/__tests__/newMemberWorker.test.ts b/backend/src/serverless/microservices/nodejs/automation/workers/__tests__/newMemberWorker.test.ts new file mode 100644 index 0000000000..bbd9283d54 --- /dev/null +++ b/backend/src/serverless/microservices/nodejs/automation/workers/__tests__/newMemberWorker.test.ts @@ -0,0 +1,56 @@ +import { + AutomationData, + AutomationState, + AutomationTrigger, + AutomationType, + NewMemberSettings, +} from '../../../../../../types/automationTypes' +import { PlatformType } from '../../../../../../utils/platforms' +import { shouldProcessMember } from '../newMemberWorker' + +function createAutomationData(settings: NewMemberSettings): AutomationData { + return { + id: '123', + state: AutomationState.ACTIVE, + trigger: AutomationTrigger.NEW_MEMBER, + settings, + type: AutomationType.WEBHOOK, + createdAt: new Date().toISOString(), + tenantId: '321', + lastExecutionAt: null, + lastExecutionError: null, + lastExecutionState: null, + } +} + +describe('New Member Automation Worker tests', () => { + it('Should process a worker that matches settings', () => { + const automation = createAutomationData({ + platforms: [PlatformType.DISCORD], + }) + + const member = { + id: '1234', + username: { + [PlatformType.DISCORD]: 'discordUsername', + }, + } + + expect(shouldProcessMember(member, automation)).toBeTruthy() + }) + + it("Shouldn't process a worker that does not match settings", () => { + const automation = createAutomationData({ + platforms: [PlatformType.DEVTO], + }) + + const member = { + id: '1234', + username: { + [PlatformType.DISCORD]: 'discordUsername', + }, + } + + expect(shouldProcessMember(member, automation)).toBeFalsy() + }) +}) diff --git a/backend/src/serverless/microservices/nodejs/automation/workers/newActivityWorker.ts b/backend/src/serverless/microservices/nodejs/automation/workers/newActivityWorker.ts new file mode 100644 index 0000000000..e3e377f705 --- /dev/null +++ b/backend/src/serverless/microservices/nodejs/automation/workers/newActivityWorker.ts @@ -0,0 +1,144 @@ +import getUserContext from '../../../../../database/utils/getUserContext' +import ActivityRepository from '../../../../../database/repositories/activityRepository' +import AutomationRepository from '../../../../../database/repositories/automationRepository' +import { + AutomationData, + AutomationState, + AutomationTrigger, + AutomationType, + NewActivitySettings, +} from '../../../../../types/automationTypes' +import { sendWebhookProcessRequest } from './util' +import { prepareMemberPayload } from './newMemberWorker' + +/** + * Helper function to check whether a single activity should be processed by automation + * @param activityData Activity data + * @param automation {AutomationData} Automation data + */ +export const shouldProcessActivity = (activityData, automation: AutomationData): boolean => { + const settings = automation.settings as NewActivitySettings + + let process = true + + // check whether activity type matches + if (settings.types && settings.types.length > 0) { + if (!settings.types.includes(activityData.type)) { + console.log( + `Ignoring automation ${automation.id} - Activity ${activityData.id} type '${ + activityData.type + }' does not match automation setting types: [${settings.types.join(', ')}]`, + ) + process = false + } + } + + // check whether activity platform matches + if (process && settings.platforms && settings.platforms.length > 0) { + if (!settings.platforms.includes(activityData.platform)) { + console.log( + `Ignoring automation ${automation.id} - Activity ${activityData.id} platform '${ + activityData.platform + }' does not match automation setting platforms: [${settings.platforms.join(', ')}]`, + ) + process = false + } + } + + // check whether activity content contains any of the keywords + if (process && settings.keywords && settings.keywords.length > 0) { + const body = (activityData.crowdInfo.body as string).toLowerCase() + if (!settings.keywords.some((keyword) => body.includes(keyword.trim().toLowerCase()))) { + console.log( + `Ignoring automation ${automation.id} - Activity ${ + activityData.id + } content does not match automation setting keywords: [${settings.keywords.join(', ')}]`, + ) + process = false + } + } + + if (process && !settings.teamMemberActivities) { + if (activityData.crowdInfo.teamMember) { + console.log( + `Ignoring automation ${automation.id} - Activity ${activityData.id} belongs to a team member!`, + ) + process = false + } + } + + return process +} + +/** + * Return a cleaned up copy of the activity that contains only data that is relevant for automation. + * + * @param activity Activity data as it came from the repository layer + * @returns a cleaned up payload to use with automation + */ +export const prepareActivityPayload = (activity: any): any => { + const copy = { ...activity } + + delete copy.importHash + delete copy.updatedAt + delete copy.updatedById + delete copy.deletedAt + if (copy.communityMember) { + copy.communityMember = prepareMemberPayload(copy.communityMember) + } + if (copy.parent) { + copy.parent = prepareActivityPayload(copy.parent) + } + + return copy +} + +/** + * Check whether this activity matches any automations for tenant. + * If so emit automation process messages to NodeJS microservices SQS queue. + * + * @param tenantId tenant unique ID + * @param activityId activity unique ID + */ +export default async (tenantId: string, activityId: string): Promise => { + console.log(`New activity automation trigger detected with activity id: ${activityId}!`) + + const userContext = await getUserContext(tenantId) + + try { + // check if relevant automations exists in this tenant + const automations = await new AutomationRepository(userContext).findAll({ + trigger: AutomationTrigger.NEW_ACTIVITY, + state: AutomationState.ACTIVE, + }) + + if (automations.length > 0) { + console.log(`Found ${automations.length} automations to process!`) + const activityData = await ActivityRepository.findById(activityId, userContext) + + for (const automation of automations) { + if (shouldProcessActivity(activityData, automation)) { + console.log(`Activity ${activityId} is being processed by automation ${automation.id}!`) + + switch (automation.type) { + case AutomationType.WEBHOOK: + await sendWebhookProcessRequest( + tenantId, + automation.id, + activityData.id, + prepareActivityPayload(activityData), + ) + break + default: + console.log(`ERROR: Automation type '${automation.type}' is not supported!`) + } + } + } + } else { + console.log(`No automations found for tenant ${tenantId} and new_activity trigger!`) + } + } catch (error) { + console.log('Error while processing new activity automation trigger!', error) + throw error + } +} diff --git a/backend/src/serverless/microservices/nodejs/automation/workers/newMemberWorker.ts b/backend/src/serverless/microservices/nodejs/automation/workers/newMemberWorker.ts new file mode 100644 index 0000000000..6fff1d19d6 --- /dev/null +++ b/backend/src/serverless/microservices/nodejs/automation/workers/newMemberWorker.ts @@ -0,0 +1,109 @@ +import getUserContext from '../../../../../database/utils/getUserContext' +import AutomationRepository from '../../../../../database/repositories/automationRepository' +import { + AutomationData, + AutomationState, + AutomationTrigger, + AutomationType, + NewMemberSettings, +} from '../../../../../types/automationTypes' +import CommunityMemberRepository from '../../../../../database/repositories/communityMemberRepository' +import { sendWebhookProcessRequest } from './util' + +/** + * Helper function to check whether a single member should be processed by automation + * @param member Member data + * @param automation {AutomationData} Automation data + */ +export const shouldProcessMember = (member, automation: AutomationData): boolean => { + const settings = automation.settings as NewMemberSettings + + let process = true + + // check whether member platforms matches + if (settings.platforms && settings.platforms.length > 0) { + const platforms = Object.keys(member.username) + if (!platforms.some((platform) => settings.platforms.includes(platform))) { + console.log( + `Ignoring automation ${automation.id} - Member ${ + member.id + } platforms do not include any of automation setting platforms: [${settings.platforms.join( + ', ', + )}]`, + ) + process = false + } + } + + return process +} + +/** + * Return a cleaned up copy of the member that contains only data that is relevant for automation. + * + * @param member Member data as it came from the repository layer + * @returns a cleaned up payload to use with automation + */ +export const prepareMemberPayload = (member: any): any => { + const copy = { ...member } + + delete copy.importHash + delete copy.signals + delete copy.type + delete copy.score + delete copy.updatedAt + delete copy.updatedById + delete copy.deletedAt + + return copy +} + +/** + * Check whether this member matches any automations for tenant. + * If so emit automation process messages to NodeJS microservices SQS queue. + * + * @param tenantId tenant unique ID + * @param memberId community member unique ID + */ +export default async (tenantId: string, memberId: string): Promise => { + console.log(`New member automation trigger detected with member id: ${memberId}!`) + + const userContext = await getUserContext(tenantId) + + try { + // check if relevant automation exists in this tenant + const automations = await new AutomationRepository(userContext).findAll({ + trigger: AutomationTrigger.NEW_MEMBER, + state: AutomationState.ACTIVE, + }) + + if (automations.length > 0) { + console.log(`Found ${automations.length} automations to process!`) + const member = await CommunityMemberRepository.findById(memberId, userContext, true, false) + + for (const automation of automations) { + if (shouldProcessMember(member, automation)) { + console.log(`Member ${memberId} is being processed by automation ${automation.id}!`) + + switch (automation.type) { + case AutomationType.WEBHOOK: + await sendWebhookProcessRequest( + tenantId, + automation.id, + member.id, + prepareMemberPayload(member), + ) + break + default: + console.log(`ERROR: Automation type '${automation.type}' is not supported!`) + } + } + } + } else { + console.log(`No automations found for tenant ${tenantId} and new_activity trigger!`) + } + } catch (error) { + console.log('Error while processing new member automation trigger!', error) + throw error + } +} diff --git a/backend/src/serverless/microservices/nodejs/automation/workers/util.ts b/backend/src/serverless/microservices/nodejs/automation/workers/util.ts new file mode 100644 index 0000000000..e03140963e --- /dev/null +++ b/backend/src/serverless/microservices/nodejs/automation/workers/util.ts @@ -0,0 +1,18 @@ +import sendNodeMicroserviceMessage from '../../nodeMicroserviceSQS' +import { AutomationType } from '../../../../../types/automationTypes' + +export const sendWebhookProcessRequest = async ( + tenant: string, + automationId: string, + eventId: string, + payload: any, +): Promise => { + await sendNodeMicroserviceMessage({ + service: 'automation-process', + automationType: AutomationType.WEBHOOK, + tenant, + automationId, + eventId, + payload, + }) +} diff --git a/backend/src/serverless/microservices/nodejs/automation/workers/webhookWorker.ts b/backend/src/serverless/microservices/nodejs/automation/workers/webhookWorker.ts new file mode 100644 index 0000000000..80538cfbf9 --- /dev/null +++ b/backend/src/serverless/microservices/nodejs/automation/workers/webhookWorker.ts @@ -0,0 +1,93 @@ +import request from 'superagent' +import getUserContext from '../../../../../database/utils/getUserContext' +import AutomationRepository from '../../../../../database/repositories/automationRepository' +import { AutomationExecutionState, WebhookSettings } from '../../../../../types/automationTypes' +import AutomationExecutionService from '../../../../../services/automationExecutionService' + +/** + * Actually fire the webhook with the relevant payload + * + * @param tenantId tenant unique ID + * @param automationId automation unique ID + * @param eventId trigger event unique ID + * @param payload payload to send + */ +export default async ( + tenantId: string, + automationId: string, + eventId: string, + payload: any, +): Promise => { + const userContext = await getUserContext(tenantId) + const automationExecutionService = new AutomationExecutionService(userContext) + + const automation = await new AutomationRepository(userContext).findById(automationId) + const settings = automation.settings as WebhookSettings + + const now = new Date() + console.log(`Firing automation ${automationId} for event ${eventId} to url '${settings.url}'!`) + const eventPayload = { + eventId, + eventType: automation.trigger, + eventExecutedAt: now.toISOString(), + eventPayload: payload, + } + + let success = false + try { + const result = await request + .post(settings.url) + .send(eventPayload) + .set('User-Agent', 'Crowd.dev Automations Executor') + .set('X-CrowdDotDev-Event-Type', automation.trigger) + .set('X-CrowdDotDev-Event-ID', eventId) + + success = true + console.log(`Webhook response code ${result.statusCode}!`) + } catch (err) { + console.log( + `Error while firing webhook automation ${automationId} for event ${eventId} to url '${settings.url}'!`, + err, + ) + + let error: any + + if (err.syscall && err.code) { + error = { + type: 'network', + message: `Could not access ${settings.url}!`, + } + } else if (err.status) { + error = { + type: 'http_status', + message: `POST @ ${settings.url} returned ${err.statusCode} - ${err.statusMessage}!`, + body: err.res.body, + } + } else { + error = { + type: 'unknown', + message: err.message, + errorObject: err, + } + } + + await automationExecutionService.create({ + automation, + eventId, + payload: eventPayload, + state: AutomationExecutionState.ERROR, + error, + }) + + throw err + } + + if (success) { + await automationExecutionService.create({ + automation, + eventId, + payload: eventPayload, + state: AutomationExecutionState.SUCCESS, + }) + } +} diff --git a/backend/src/serverless/microservices/nodejs/handler.ts b/backend/src/serverless/microservices/nodejs/handler.ts index bdc41738c3..53d3fd03e5 100644 --- a/backend/src/serverless/microservices/nodejs/handler.ts +++ b/backend/src/serverless/microservices/nodejs/handler.ts @@ -1,5 +1,5 @@ import weeklyAnalyticsEmailsCoordinator from './analytics/coordinators/weeklyAnalyticsEmailsCoordinator' -import workerFactory from './analytics/workers/workerFactory' +import workerFactory from './workerFactory' import { stepFunctions } from '../../../services/aws' export async function handlerWeeklyAnalyticsEmailsCoordinator() { diff --git a/backend/src/serverless/microservices/nodejs/messageTypes.ts b/backend/src/serverless/microservices/nodejs/messageTypes.ts index 841e88306c..4819e7b76b 100644 --- a/backend/src/serverless/microservices/nodejs/messageTypes.ts +++ b/backend/src/serverless/microservices/nodejs/messageTypes.ts @@ -1,8 +1,40 @@ -export type NodeMicroserviceMessage = { +import { AutomationTrigger, AutomationType } from '../../../types/automationTypes' + +export type BaseNodeMicroserviceMessage = { service: string tenant?: string } +export type AutomationMessage = BaseNodeMicroserviceMessage & { + trigger: AutomationTrigger +} + +export type NewActivityAutomationMessage = BaseNodeMicroserviceMessage & { + activityId: string +} + +export type NewMemberAutomationMessage = BaseNodeMicroserviceMessage & { + memberId: string +} + +export type ProcessAutomationMessage = BaseNodeMicroserviceMessage & { + automationType: AutomationType +} + +export type ProcessWebhookAutomationMessage = BaseNodeMicroserviceMessage & { + automationId: string + eventId: string + payload: any +} + +export type NodeMicroserviceMessage = + | BaseNodeMicroserviceMessage + | AutomationMessage + | NewActivityAutomationMessage + | NewMemberAutomationMessage + | ProcessAutomationMessage + | ProcessWebhookAutomationMessage + export type BaseOutput = { status: number; msg?: string } export interface AnalyticsEmailsOutput extends BaseOutput { diff --git a/backend/src/serverless/microservices/nodejs/nodeMicroserviceSQS.ts b/backend/src/serverless/microservices/nodejs/nodeMicroserviceSQS.ts index 7680a65bb2..b62ffd8d69 100644 --- a/backend/src/serverless/microservices/nodejs/nodeMicroserviceSQS.ts +++ b/backend/src/serverless/microservices/nodejs/nodeMicroserviceSQS.ts @@ -2,6 +2,7 @@ import moment from 'moment' import { NodeMicroserviceMessage } from './messageTypes' import { getConfig } from '../../../config' import { sqs } from '../../../services/aws' +import { AutomationTrigger } from '../../../types/automationTypes' /** * Send a message to the node microservice queue @@ -13,14 +14,24 @@ async function sendNodeMicroserviceMessage(body: NodeMicroserviceMessage): Promi console.log('SQS Message body: ', body) + const config = getConfig() + if (config.NODE_ENV === 'test') { + return { + status: statusCode, + msg: JSON.stringify({ + body, + }), + } + } + const messageGroupId = body.tenant ? `${body.service}-${body.tenant}` : `${body.service}` const messageDeduplicationId = body.tenant - ? `${body.service}-${body.tenant}-${moment().unix()}` - : `${body.service}-${moment().unix()}` + ? `${body.service}-${body.tenant}-${moment().valueOf()}` + : `${body.service}-${moment().valueOf()}` await sqs .sendMessage({ - QueueUrl: getConfig().NODE_MICROSERVICES_SQS_URL, + QueueUrl: config.NODE_MICROSERVICES_SQS_URL, MessageGroupId: messageGroupId, MessageDeduplicationId: messageDeduplicationId, MessageBody: JSON.stringify(body), @@ -37,4 +48,28 @@ async function sendNodeMicroserviceMessage(body: NodeMicroserviceMessage): Promi } } +export const sendNewActivityNodeSQSMessage = async ( + tenant: string, + activityId: string, +): Promise => { + await sendNodeMicroserviceMessage({ + tenant, + activityId, + trigger: AutomationTrigger.NEW_ACTIVITY, + service: 'automation', + }) +} + +export const sendNewMemberNodeSQSMessage = async ( + tenant: string, + memberId: string, +): Promise => { + await sendNodeMicroserviceMessage({ + tenant, + memberId, + trigger: AutomationTrigger.NEW_MEMBER, + service: 'automation', + }) +} + export default sendNodeMicroserviceMessage diff --git a/backend/src/serverless/microservices/nodejs/package-lock.json b/backend/src/serverless/microservices/nodejs/package-lock.json index 306ef5d700..eeef1c53e8 100644 --- a/backend/src/serverless/microservices/nodejs/package-lock.json +++ b/backend/src/serverless/microservices/nodejs/package-lock.json @@ -31,6 +31,7 @@ "sequelize": "6.21.2", "serverless-http": "^2.7.0", "serverless-s3-sync": "^3.0.0", + "superagent": "^8.0.0", "utils": "^0.3.1", "verify-github-webhook": "^1.0.1", "vm2": "^3.9.6", @@ -38,6 +39,7 @@ }, "devDependencies": { "@types/jest": "^27.4.0", + "@types/superagent": "^4.1.15", "copy-webpack-plugin": "^11.0.0", "dotenv": "^14.3.2", "lodash": "^4.17.21", @@ -2459,6 +2461,12 @@ "@types/responselike": "*" } }, + "node_modules/@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 + }, "node_modules/@types/datadog-metrics": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/@types/datadog-metrics/-/datadog-metrics-0.6.1.tgz", @@ -2634,6 +2642,16 @@ "dev": true, "peer": true }, + "node_modules/@types/superagent": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.15.tgz", + "integrity": "sha512-mu/N4uvfDN2zVQQ5AYJI/g4qxn2bHB6521t1UuH09ShNWjebTqN0ZFuYK9uYjcgmI0dTQEs+Owi1EO6U0OkOZQ==", + "dev": true, + "dependencies": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, "node_modules/@types/validator": { "version": "13.7.5", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.5.tgz", @@ -3293,8 +3311,7 @@ "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "peer": true + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, "node_modules/asl-validator": { "version": "1.10.0", @@ -4743,8 +4760,7 @@ "node_modules/component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", - "peer": true + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" }, "node_modules/compress-brotli": { "version": "1.3.8", @@ -4860,8 +4876,7 @@ "node_modules/cookiejar": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz", - "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==", - "peer": true + "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==" }, "node_modules/copy-webpack-plugin": { "version": "11.0.0", @@ -5501,7 +5516,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.3.tgz", "integrity": "sha512-K7i4zNfT2kgQz3GylDw40ot9GAE47sFZ9EXHFSPP6zONLgH6kWXE0KWJchkbQJLBkRazq4APwZ4OwiFFlT95OQ==", - "peer": true, "dependencies": { "asap": "^2.0.0", "wrappy": "1" @@ -6188,8 +6202,7 @@ "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "peer": true + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, "node_modules/fast-xml-parser": { "version": "3.19.0", @@ -7013,7 +7026,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", - "peer": true, "engines": { "node": ">=8" } @@ -10905,6 +10917,149 @@ "superagent": "^7.1.6" } }, + "node_modules/path-loader/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "peer": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/path-loader/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "peer": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-loader/node_modules/formidable": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz", + "integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==", + "peer": true, + "dependencies": { + "dezalgo": "1.0.3", + "hexoid": "1.0.0", + "once": "1.4.0", + "qs": "6.9.3" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/path-loader/node_modules/formidable/node_modules/qs": { + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz", + "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==", + "peer": true, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/path-loader/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "peer": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/path-loader/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "peer": true + }, + "node_modules/path-loader/node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "peer": true, + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/path-loader/node_modules/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==", + "peer": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-loader/node_modules/semver": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", + "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", + "peer": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/path-loader/node_modules/superagent": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.6.tgz", + "integrity": "sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g==", + "deprecated": "Please downgrade to v7.1.5 if you need IE/ActiveXObject support OR upgrade to v8.0.0 as we no longer support IE and published an incorrect patch version (see https://github.com/visionmedia/superagent/issues/1731)", + "peer": true, + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.3", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.0.1", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.10.3", + "readable-stream": "^3.6.0", + "semver": "^7.3.7" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -13373,11 +13528,9 @@ } }, "node_modules/superagent": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.6.tgz", - "integrity": "sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g==", - "deprecated": "Please downgrade to v7.1.5 if you need IE/ActiveXObject support OR upgrade to v8.0.0 as we no longer support IE and published an incorrect patch version (see https://github.com/visionmedia/superagent/issues/1731)", - "peer": true, + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.0.tgz", + "integrity": "sha512-iudipXEel+SzlP9y29UBWGDjB+Zzag+eeA1iLosaR2YHBRr1Q1kC29iBrF2zIVD9fqVbpZnXkN/VJmwFMVyNWg==", "dependencies": { "component-emitter": "^1.3.0", "cookiejar": "^2.1.3", @@ -13399,7 +13552,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "peer": true, "dependencies": { "ms": "2.1.2" }, @@ -13416,7 +13568,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "peer": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -13430,7 +13581,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz", "integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==", - "peer": true, "dependencies": { "dezalgo": "1.0.3", "hexoid": "1.0.0", @@ -13445,7 +13595,6 @@ "version": "6.9.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz", "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==", - "peer": true, "engines": { "node": ">=0.6" }, @@ -13457,7 +13606,6 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "peer": true, "bin": { "mime": "cli.js" }, @@ -13468,14 +13616,12 @@ "node_modules/superagent/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "peer": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/superagent/node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "peer": true, "dependencies": { "side-channel": "^1.0.4" }, @@ -13490,7 +13636,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -13504,7 +13649,6 @@ "version": "7.3.7", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "peer": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -17165,6 +17309,12 @@ "@types/responselike": "*" } }, + "@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/datadog-metrics": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/@types/datadog-metrics/-/datadog-metrics-0.6.1.tgz", @@ -17340,6 +17490,16 @@ "dev": true, "peer": true }, + "@types/superagent": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.15.tgz", + "integrity": "sha512-mu/N4uvfDN2zVQQ5AYJI/g4qxn2bHB6521t1UuH09ShNWjebTqN0ZFuYK9uYjcgmI0dTQEs+Owi1EO6U0OkOZQ==", + "dev": true, + "requires": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, "@types/validator": { "version": "13.7.5", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.5.tgz", @@ -17889,8 +18049,7 @@ "asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "peer": true + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, "asl-validator": { "version": "1.10.0", @@ -18986,8 +19145,7 @@ "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", - "peer": true + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" }, "compress-brotli": { "version": "1.3.8", @@ -19084,8 +19242,7 @@ "cookiejar": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz", - "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==", - "peer": true + "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==" }, "copy-webpack-plugin": { "version": "11.0.0", @@ -19597,7 +19754,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.3.tgz", "integrity": "sha512-K7i4zNfT2kgQz3GylDw40ot9GAE47sFZ9EXHFSPP6zONLgH6kWXE0KWJchkbQJLBkRazq4APwZ4OwiFFlT95OQ==", - "peer": true, "requires": { "asap": "^2.0.0", "wrappy": "1" @@ -20159,8 +20315,7 @@ "fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "peer": true + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, "fast-xml-parser": { "version": "3.19.0", @@ -20768,8 +20923,7 @@ "hexoid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", - "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", - "peer": true + "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==" }, "highlight.js": { "version": "10.7.3", @@ -23794,6 +23948,108 @@ "requires": { "native-promise-only": "^0.8.1", "superagent": "^7.1.6" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "peer": true, + "requires": { + "ms": "2.1.2" + } + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "peer": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "formidable": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz", + "integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==", + "peer": true, + "requires": { + "dezalgo": "1.0.3", + "hexoid": "1.0.0", + "once": "1.4.0", + "qs": "6.9.3" + }, + "dependencies": { + "qs": { + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz", + "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==", + "peer": true + } + } + }, + "mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "peer": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "peer": true + }, + "qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "peer": true, + "requires": { + "side-channel": "^1.0.4" + } + }, + "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==", + "peer": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "semver": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", + "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", + "peer": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "superagent": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.6.tgz", + "integrity": "sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g==", + "peer": true, + "requires": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.3", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.0.1", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.10.3", + "readable-stream": "^3.6.0", + "semver": "^7.3.7" + } + } } }, "path-parse": { @@ -25647,10 +25903,9 @@ } }, "superagent": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.6.tgz", - "integrity": "sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g==", - "peer": true, + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.0.tgz", + "integrity": "sha512-iudipXEel+SzlP9y29UBWGDjB+Zzag+eeA1iLosaR2YHBRr1Q1kC29iBrF2zIVD9fqVbpZnXkN/VJmwFMVyNWg==", "requires": { "component-emitter": "^1.3.0", "cookiejar": "^2.1.3", @@ -25669,7 +25924,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "peer": true, "requires": { "ms": "2.1.2" } @@ -25678,7 +25932,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "peer": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -25689,7 +25942,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz", "integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==", - "peer": true, "requires": { "dezalgo": "1.0.3", "hexoid": "1.0.0", @@ -25700,28 +25952,24 @@ "qs": { "version": "6.9.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz", - "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==", - "peer": true + "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==" } } }, "mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "peer": true + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==" }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "peer": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "peer": true, "requires": { "side-channel": "^1.0.4" } @@ -25730,7 +25978,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "peer": true, "requires": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -25741,7 +25988,6 @@ "version": "7.3.7", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "peer": true, "requires": { "lru-cache": "^6.0.0" } diff --git a/backend/src/serverless/microservices/nodejs/package.json b/backend/src/serverless/microservices/nodejs/package.json index 348c86049c..7ade5a388d 100644 --- a/backend/src/serverless/microservices/nodejs/package.json +++ b/backend/src/serverless/microservices/nodejs/package.json @@ -37,6 +37,7 @@ "sequelize": "6.21.2", "serverless-http": "^2.7.0", "serverless-s3-sync": "^3.0.0", + "superagent": "^8.0.0", "utils": "^0.3.1", "verify-github-webhook": "^1.0.1", "vm2": "^3.9.6", @@ -44,6 +45,7 @@ }, "devDependencies": { "@types/jest": "^27.4.0", + "@types/superagent": "^4.1.15", "copy-webpack-plugin": "^11.0.0", "dotenv": "^14.3.2", "lodash": "^4.17.21", diff --git a/backend/src/serverless/microservices/nodejs/serverless.yml b/backend/src/serverless/microservices/nodejs/serverless.yml index f6b7fdb920..797ef15548 100644 --- a/backend/src/serverless/microservices/nodejs/serverless.yml +++ b/backend/src/serverless/microservices/nodejs/serverless.yml @@ -103,7 +103,7 @@ functions: NODE_ENV: ${env:NODE_ENV} EDITION: ${env:EDITION} SEGMENT_WRITE_KEY: ${env:SEGMENT_WRITE_KEY} - NODE_MICROSERVICES_QUEUE_URL: ${env:NODE_MICROSERVICES_SQS_URL} + NODE_MICROSERVICES_SQS_URL: ${env:NODE_MICROSERVICES_SQS_URL} DATABASE_USERNAME: ${env:DATABASE_USERNAME} DATABASE_DIALECT: ${env:DATABASE_DIALECT} DATABASE_PASSWORD: ${env:DATABASE_PASSWORD} diff --git a/backend/src/serverless/microservices/nodejs/workerFactory.ts b/backend/src/serverless/microservices/nodejs/workerFactory.ts new file mode 100644 index 0000000000..ad650fd563 --- /dev/null +++ b/backend/src/serverless/microservices/nodejs/workerFactory.ts @@ -0,0 +1,65 @@ +/* eslint-disable no-case-declarations */ +import { weeklyAnalyticsEmailsWorker } from './analytics/workers/weeklyAnalyticsEmailsWorker' +import { + AutomationMessage, + NewActivityAutomationMessage, + NewMemberAutomationMessage, + NodeMicroserviceMessage, + ProcessAutomationMessage, + ProcessWebhookAutomationMessage, +} from './messageTypes' +import { AutomationTrigger, AutomationType } from '../../../types/automationTypes' +import newActivityWorker from './automation/workers/newActivityWorker' +import newMemberWorker from './automation/workers/newMemberWorker' +import webhookWorker from './automation/workers/webhookWorker' + +/** + * Worker factory for spawning different microservices + * according to event.service + * @param event + * @returns worker function promise + */ + +async function workerFactory(event: NodeMicroserviceMessage): Promise { + console.log('Starting main worker with event, ', event) + + const { service, tenant } = event as any + + switch (service.toLowerCase()) { + case 'weekly-analytics-emails': + return weeklyAnalyticsEmailsWorker(tenant) + case 'automation-process': + const automationProcessRequest = event as ProcessAutomationMessage + + switch (automationProcessRequest.automationType) { + case AutomationType.WEBHOOK: + const webhookProcessRequest = event as ProcessWebhookAutomationMessage + return webhookWorker( + tenant, + webhookProcessRequest.automationId, + webhookProcessRequest.eventId, + webhookProcessRequest.payload, + ) + default: + throw new Error(`Invalid automation type ${automationProcessRequest.automationType}!`) + } + + case 'automation': + const automationRequest = event as AutomationMessage + + switch (automationRequest.trigger) { + case AutomationTrigger.NEW_ACTIVITY: + const newActivityAutomationRequest = event as NewActivityAutomationMessage + return newActivityWorker(tenant, newActivityAutomationRequest.activityId) + case AutomationTrigger.NEW_MEMBER: + const newMemberAutomationRequest = event as NewMemberAutomationMessage + return newMemberWorker(tenant, newMemberAutomationRequest.memberId) + default: + throw new Error(`Invalid automation trigger ${automationRequest.trigger}!`) + } + default: + throw new Error(`Invalid microservice ${service}`) + } +} + +export default workerFactory diff --git a/backend/src/services/activityService.ts b/backend/src/services/activityService.ts index 35fd0d2658..a33d52554d 100644 --- a/backend/src/services/activityService.ts +++ b/backend/src/services/activityService.ts @@ -10,6 +10,7 @@ import CommunityMemberService from './communityMemberService' import ConversationService from './conversationService' import telemetryTrack from '../segment/telemetryTrack' import ConversationSettingsService from './conversationSettingsService' +import { sendNewActivityNodeSQSMessage } from '../serverless/microservices/nodejs/nodeMicroserviceSQS' export default class ActivityService { options: IServiceOptions @@ -118,6 +119,14 @@ export default class ActivityService { await SequelizeRepository.commitTransaction(transaction) + if (!existing) { + sendNewActivityNodeSQSMessage(this.options.currentTenant.id, record.id) + .then(() => console.log(`New activity automation triggered - ${record.id}!`)) + .catch((err) => + console.log(`Error triggering new activity automation - ${record.id}!`, err), + ) + } + return record } catch (error) { await SequelizeRepository.rollbackTransaction(transaction) diff --git a/backend/src/services/automationExecutionService.ts b/backend/src/services/automationExecutionService.ts new file mode 100644 index 0000000000..f6eb5d1ee2 --- /dev/null +++ b/backend/src/services/automationExecutionService.ts @@ -0,0 +1,75 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable class-methods-use-this */ +import { ServiceBase } from './serviceBase' +import { + AutomationExecution, + AutomationExecutionCriteria, + CreateAutomationExecutionRequest, +} from '../types/automationTypes' +import { IServiceOptions } from './IServiceOptions' +import SequelizeRepository from '../database/repositories/sequelizeRepository' +import AutomationExecutionRepository from '../database/repositories/automationExecutionRepository' +import { PageData } from '../types/common' + +export default class AutomationExecutionService extends ServiceBase< + AutomationExecution, + string, + CreateAutomationExecutionRequest, + unknown, + AutomationExecutionCriteria +> { + public constructor(options: IServiceOptions) { + super(options) + } + + /** + * Method used by service that is processing automations as they are triggered + * @param data {CreateAutomationExecutionRequest} all the necessary data to log a new automation execution + */ + override async create(data: CreateAutomationExecutionRequest): Promise { + const transaction = await SequelizeRepository.createTransaction(this.options.database) + + try { + const record = await new AutomationExecutionRepository(this.options).create({ + automationId: data.automation.id, + type: data.automation.type, + tenantId: data.automation.tenantId, + trigger: data.automation.trigger, + error: data.error !== undefined ? data.error : null, + executedAt: new Date(), + state: data.state, + eventId: data.eventId, + payload: data.payload, + }) + await SequelizeRepository.commitTransaction(transaction) + + return record + } catch (error) { + await SequelizeRepository.rollbackTransaction(transaction) + throw error + } + } + + /** + * Method used to fetch all automation executions. + * @param criteria {AutomationExecutionCriteria} filters to be used when returning automation executions + * @returns {PageData>} + */ + override async findAndCountAll( + criteria: AutomationExecutionCriteria, + ): Promise> { + return new AutomationExecutionRepository(this.options).findAndCountAll(criteria) + } + + override async update(id: string, data: unknown): Promise { + throw new Error('Method not implemented.') + } + + override async destroyAll(ids: string[]): Promise { + throw new Error('Method not implemented.') + } + + override async findById(id: string): Promise { + throw new Error('Method not implemented.') + } +} diff --git a/backend/src/services/automationService.ts b/backend/src/services/automationService.ts new file mode 100644 index 0000000000..aa5bf2b7e4 --- /dev/null +++ b/backend/src/services/automationService.ts @@ -0,0 +1,106 @@ +import { + AutomationCriteria, + AutomationData, + AutomationState, + CreateAutomationRequest, + UpdateAutomationRequest, +} from '../types/automationTypes' +import { IServiceOptions } from './IServiceOptions' +import SequelizeRepository from '../database/repositories/sequelizeRepository' +import AutomationRepository from '../database/repositories/automationRepository' +import { PageData } from '../types/common' +import { ServiceBase } from './serviceBase' + +export default class AutomationService extends ServiceBase< + AutomationData, + string, + CreateAutomationRequest, + UpdateAutomationRequest, + AutomationCriteria +> { + public constructor(options: IServiceOptions) { + super(options) + } + + /** + * Creates a new active automation + * @param req {CreateAutomationRequest} data used to create a new automation + * @returns {AutomationData} object for frontend to use + */ + override async create(req: CreateAutomationRequest): Promise { + const txOptions = await this.getTxRepositoryOptions() + + try { + // create an active automation + const result = await new AutomationRepository(txOptions).create({ + ...req, + state: AutomationState.ACTIVE, + }) + + await SequelizeRepository.commitTransaction(txOptions.transaction) + + return result + } catch (error) { + await SequelizeRepository.rollbackTransaction(txOptions.transaction) + throw error + } + } + + /** + * Updates an existing automation. + * Also used to change automation state - to enable or disable an automation. + * It updates all the columns at once so all the properties in the request parameter + * have to be filled. + * @param id of the existing automation that is being updated + * @param req {UpdateAutomationRequest} data used to update an existing automation + * @returns {AutomationData} object for frontend to use + */ + override async update(id: string, req: UpdateAutomationRequest): Promise { + const txOptions = await this.getTxRepositoryOptions() + + try { + // update an existing automation including its state + const result = await new AutomationRepository(txOptions).update(id, req) + await SequelizeRepository.commitTransaction(txOptions.transaction) + return result + } catch (error) { + await SequelizeRepository.rollbackTransaction(txOptions.transaction) + throw error + } + } + + /** + * Method used to fetch all tenants automation with filters available in the criteria parameter + * @param criteria {AutomationCriteria} filters to be used when returning automations + * @returns {PageData>} + */ + override async findAndCountAll(criteria: AutomationCriteria): Promise> { + return new AutomationRepository(this.options).findAndCountAll(criteria) + } + + /** + * Method used to fetch a single automation by its id + * @param id automation id + * @returns {AutomationData} + */ + override async findById(id: string): Promise { + return new AutomationRepository(this.options).findById(id) + } + + /** + * Deletes existing automations by id + * @param ids automation unique IDs to be deleted + */ + override async destroyAll(ids: string[]): Promise { + const txOptions = await this.getTxRepositoryOptions() + + try { + const result = await new AutomationRepository(txOptions).destroyAll(ids) + await SequelizeRepository.commitTransaction(txOptions.transaction) + return result + } catch (error) { + await SequelizeRepository.rollbackTransaction(txOptions.transaction) + throw error + } + } +} diff --git a/backend/src/services/communityMemberService.ts b/backend/src/services/communityMemberService.ts index f402ae3041..a620e3d56d 100644 --- a/backend/src/services/communityMemberService.ts +++ b/backend/src/services/communityMemberService.ts @@ -8,6 +8,7 @@ import CommunityMemberRepository from '../database/repositories/communityMemberR import ActivityRepository from '../database/repositories/activityRepository' import TagRepository from '../database/repositories/tagRepository' import telemetryTrack from '../segment/telemetryTrack' +import { sendNewMemberNodeSQSMessage } from '../serverless/microservices/nodejs/nodeMicroserviceSQS' export default class CommunityMemberService { options: IServiceOptions @@ -129,6 +130,14 @@ export default class CommunityMemberService { await SequelizeRepository.commitTransaction(transaction) + if (!existing) { + sendNewMemberNodeSQSMessage(this.options.currentTenant.id, record.id) + .then(() => console.log(`New member automation triggered - ${record.id}!`)) + .catch((err) => + console.log(`Error triggering new member automation - ${record.id}!`, err), + ) + } + return record } catch (error) { await SequelizeRepository.rollbackTransaction(transaction) diff --git a/backend/src/services/serviceBase.ts b/backend/src/services/serviceBase.ts new file mode 100644 index 0000000000..817185f2af --- /dev/null +++ b/backend/src/services/serviceBase.ts @@ -0,0 +1,31 @@ +/* eslint-disable class-methods-use-this,@typescript-eslint/no-unused-vars */ +import { IServiceOptions } from './IServiceOptions' +import { PageData, SearchCriteria } from '../types/common' +import { IRepositoryOptions } from '../database/repositories/IRepositoryOptions' +import SequelizeRepository from '../database/repositories/sequelizeRepository' + +export abstract class ServiceBase { + protected constructor(public readonly options: IServiceOptions) {} + + abstract create(data: TCreate): Promise + + abstract update(id: TId, data: TUpdate): Promise + + destroy(id: TId): Promise { + return this.destroyAll([id]) + } + + abstract destroyAll(ids: TId[]): Promise + + abstract findById(id: TId): Promise + + abstract findAndCountAll(criteria: TCriteria): Promise> + + protected async getTxRepositoryOptions(): Promise { + const transaction = await SequelizeRepository.createTransaction(this.options.database) + const options: IRepositoryOptions = { ...this.options } + options.transaction = transaction + + return options + } +} diff --git a/backend/src/types/automationTypes.ts b/backend/src/types/automationTypes.ts new file mode 100644 index 0000000000..0ad1f26343 --- /dev/null +++ b/backend/src/types/automationTypes.ts @@ -0,0 +1,133 @@ +/** + * all automation types that we are currently supporting + */ +import { SearchCriteria } from './common' + +export enum AutomationType { + WEBHOOK = 'webhook', +} + +/** + * automation can either be active or disabled + */ +export enum AutomationState { + ACTIVE = 'active', + DISABLED = 'disabled', +} + +/** + * To determine the result of the execution if state == error -> error column will also be available + */ +export enum AutomationExecutionState { + SUCCESS = 'success', + ERROR = 'error', +} + +/** + * What can trigger this automation + */ +export enum AutomationTrigger { + NEW_ACTIVITY = 'new_activity', + NEW_MEMBER = 'new_member', +} + +/** + * For webhook automation we only need URL to which we will post information + */ +export interface WebhookSettings { + url: string +} + +/** + * Settings for new activity trigger based automations + */ +export interface NewActivitySettings { + types: string[] + platforms: string[] + keywords: string[] + teamMemberActivities: boolean +} + +/** + * Settings for new member trigger based automations + */ +export interface NewMemberSettings { + platforms: string[] +} + +/** + * Union type to contain all different types of settings + */ +export type AutomationSettings = WebhookSettings | NewActivitySettings | NewMemberSettings + +/** + * This data is used by the frontend to display automations settings page + */ +export interface AutomationData { + id: string + type: AutomationType + tenantId: string + trigger: AutomationTrigger + settings: AutomationSettings + state: AutomationState + createdAt: string + lastExecutionAt: string | null + lastExecutionState: AutomationExecutionState | null + lastExecutionError: unknown | null +} + +/** + * This data is used to create a new automation + */ +export interface CreateAutomationRequest { + type: AutomationType + trigger: AutomationTrigger + settings: AutomationSettings +} + +/** + * This data is used to update an existing automation + */ +export interface UpdateAutomationRequest { + trigger: AutomationTrigger + settings: AutomationSettings + state: AutomationState +} + +/** + * What filters we have available to list all automations + */ +export interface AutomationCriteria extends SearchCriteria { + id?: string + type?: AutomationType + trigger?: AutomationTrigger + state?: AutomationState +} + +export interface CreateAutomationExecutionRequest { + automation: AutomationData + eventId: string + payload: any + state: AutomationExecutionState + error?: any +} + +/** + * Data about specific automation execution that was processed when a trigger was detected + */ +export interface AutomationExecution { + id: string + automationId: string + state: AutomationExecutionState + error: any | null + executedAt: string + eventId: string + payload: any +} + +/** + * What filters we have available to list all automations + */ +export interface AutomationExecutionCriteria extends SearchCriteria { + automationId: string +} diff --git a/backend/src/types/common.ts b/backend/src/types/common.ts new file mode 100644 index 0000000000..709300c8b0 --- /dev/null +++ b/backend/src/types/common.ts @@ -0,0 +1,11 @@ +export interface PageData { + rows: T[] + count: number + limit: number + offset: number +} + +export interface SearchCriteria { + limit?: number + offset?: number +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 64b208ecb4..d8e5b99d24 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 2, "requires": true, "packages": { @@ -36,6 +36,7 @@ "uuid": "8.3.0", "vue": "^3.2.29", "vue-grid-layout": "3.0.0-beta1", + "vue-json-pretty": "^2.2.2", "vue-router": "^4.0.12", "vuedraggable": "^2.24.3", "vuex": "^4.0.2", @@ -14728,6 +14729,18 @@ "integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==", "dev": true }, + "node_modules/vue-json-pretty": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/vue-json-pretty/-/vue-json-pretty-2.2.2.tgz", + "integrity": "sha512-eZkcJKrzGmKNto0ZqTjUkIFP1SMaoDxW6VLtRj0awmIqpN7glFZLBm5tNYMJcdgTGweDp4zIPyvaewZVs3Exvg==", + "engines": { + "node": ">= 10.0.0", + "npm": ">= 5.0.0" + }, + "peerDependencies": { + "vue": ">=3.0.0" + } + }, "node_modules/vue-loader": { "version": "16.8.3", "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.8.3.tgz", @@ -26875,6 +26888,12 @@ "integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==", "dev": true }, + "vue-json-pretty": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/vue-json-pretty/-/vue-json-pretty-2.2.2.tgz", + "integrity": "sha512-eZkcJKrzGmKNto0ZqTjUkIFP1SMaoDxW6VLtRj0awmIqpN7glFZLBm5tNYMJcdgTGweDp4zIPyvaewZVs3Exvg==", + "requires": {} + }, "vue-loader": { "version": "16.8.3", "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.8.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 87f3f83f19..00b85ed0fe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,6 +41,7 @@ "uuid": "8.3.0", "vue": "^3.2.29", "vue-grid-layout": "3.0.0-beta1", + "vue-json-pretty": "^2.2.2", "vue-router": "^4.0.12", "vuedraggable": "^2.24.3", "vuex": "^4.0.2", diff --git a/frontend/public/images/automations-empty-state.svg b/frontend/public/images/automations-empty-state.svg new file mode 100644 index 0000000000..355f5e1ae3 --- /dev/null +++ b/frontend/public/images/automations-empty-state.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/scss/buttons.scss b/frontend/src/assets/scss/buttons.scss index a38b3eb029..7f62d064af 100644 --- a/frontend/src/assets/scss/buttons.scss +++ b/frontend/src/assets/scss/buttons.scss @@ -1,5 +1,5 @@ .btn { - @apply px-4 py-3 rounded-lg shadow-sm font-semibold inline-flex items-center justify-center leading-none text-sm h-auto; + @apply px-4 py-3 rounded shadow-sm font-semibold inline-flex items-center justify-center leading-none text-sm h-auto; transition: all 0.2s; &.el-button { @@ -36,7 +36,9 @@ } &[disabled] { - @apply bg-primary-900 border border-primary-900 text-white; + @apply border text-white opacity-100; + border: 1px solid #F6B9AB; + background: #F6B9AB; } } @@ -87,6 +89,14 @@ } } + &--text { + @apply bg-transparent border-0 shadow-none text-primary-900; + &:hover, &:focus { + @apply text-primary-900; + background: #FDEDEA; + } + } + &--full { @apply w-full; } diff --git a/frontend/src/assets/scss/drawer.scss b/frontend/src/assets/scss/drawer.scss new file mode 100644 index 0000000000..831899654a --- /dev/null +++ b/frontend/src/assets/scss/drawer.scss @@ -0,0 +1,7 @@ +.el-drawer { + --el-drawer-padding-primary: 24px; + + &__header { + @apply mb-4; + } +} \ No newline at end of file diff --git a/frontend/src/assets/scss/dropdown.scss b/frontend/src/assets/scss/dropdown.scss index 9ac218da9c..7b44f6affe 100644 --- a/frontend/src/assets/scss/dropdown.scss +++ b/frontend/src/assets/scss/dropdown.scss @@ -1,20 +1,20 @@ .el-dropdown-menu__item, .el-dropdown-menu__item a { - @apply flex items-center text-gray-900; + @apply flex items-center text-gray-900 px-4 py-3; i { @apply mr-2; } &:focus, &:not(.is-disabled):hover, &:not(.is-disabled):focus { - @apply text-gray-900; - background-color: #fffbf8; + @apply text-gray-900 font-medium; + background: #F8FAFC; } } .el-dropdown-title { - @apply block font-semibold pb-2 px-4 text-sm text-gray-900; + @apply block font-bold pb-2 px-4 text-sm text-gray-900; } .el-dropdown-separator { - @apply mx-4 my-2 border-gray-100; + @apply mx-4 my-2 border-gray-50; } \ No newline at end of file diff --git a/frontend/src/assets/scss/form.scss b/frontend/src/assets/scss/form.scss index 8d57b8c96d..e60e56c13d 100644 --- a/frontend/src/assets/scss/form.scss +++ b/frontend/src/assets/scss/form.scss @@ -36,8 +36,7 @@ } .el-form-item__label { - @apply font-semibold text-black h-6 leading-6; - float: none; + @apply font-medium text-black h-7 leading-6; } .el-form-item.is-required:not(.is-no-asterisk) > .el-form-item__label:before { display: none; @@ -62,7 +61,7 @@ .el-checkbox { &__label { - @apply text-black; + @apply text-black font-normal; } &__input { @@ -119,13 +118,6 @@ @apply border-secondary-900; } -.el-switch__label.is-active { - @apply text-secondary-900; -} -.el-switch.is-checked .el-switch__core { - @apply bg-secondary-900 border-secondary-900; -} - .el-form-item.is-error .el-input__inner, .el-form-item.is-error .el-input__inner:focus, .el-form-item.is-error .el-textarea__inner, diff --git a/frontend/src/assets/scss/index.scss b/frontend/src/assets/scss/index.scss index fd1cf84bc6..4c144feed4 100644 --- a/frontend/src/assets/scss/index.scss +++ b/frontend/src/assets/scss/index.scss @@ -27,5 +27,6 @@ @import 'pagination'; @import 'toast'; @import 'tabs'; +@import 'drawer'; @import '~remixicon/fonts/remixicon.css'; \ No newline at end of file diff --git a/frontend/src/assets/scss/layout.scss b/frontend/src/assets/scss/layout.scss index 4e70811c3d..b7408ac53c 100644 --- a/frontend/src/assets/scss/layout.scss +++ b/frontend/src/assets/scss/layout.scss @@ -142,9 +142,9 @@ hr { @apply bg-transparent; } .el-loading-spinner { - @apply relative top-auto mt-auto flex items-center justify-center; + @apply relative flex items-center justify-center h-full; .circular { - @apply w-5 h-5; + @apply w-10 h-10; } } } diff --git a/frontend/src/assets/scss/tabs.scss b/frontend/src/assets/scss/tabs.scss index 9559d21d1f..f8de31f8d1 100644 --- a/frontend/src/assets/scss/tabs.scss +++ b/frontend/src/assets/scss/tabs.scss @@ -17,7 +17,4 @@ box-shadow: none; } } - &__nav-wrap::after { - @apply hidden; - } } \ No newline at end of file diff --git a/frontend/src/i18n/en.js b/frontend/src/i18n/en.js index 13f885dd74..7f0cfe7219 100644 --- a/frontend/src/i18n/en.js +++ b/frontend/src/i18n/en.js @@ -352,6 +352,33 @@ const en = { menu: 'Eagle Eye' }, + automation: { + name: 'Automations', + label: 'Automations', + create: { + success: 'Automation successfully saved' + }, + update: { + success: 'Automation successfully saved' + }, + destroy: { + success: 'Automation successfully deleted' + }, + destroyAll: { + success: 'Automation(s) successfully deleted' + }, + fields: { + type: 'Type', + trigger: 'Choose Trigger', + status: 'Status' + }, + triggers: { + new_activity: + 'New activity happened in your community', + new_member: 'New member joined your community' + } + }, + conversation: { name: 'Conversations', label: 'Conversations', diff --git a/frontend/src/jsons/activity-types.json b/frontend/src/jsons/activity-types.json new file mode 100644 index 0000000000..2a486c5427 --- /dev/null +++ b/frontend/src/jsons/activity-types.json @@ -0,0 +1,40 @@ +{ + "github": [ + "discussion-started", + "pull_request-opened", + "pull_request-closed", + "issues-opened", + "issues-closed", + "fork", + "star", + "unstar", + "pull_request-comment", + "issue-comment", + "discussion-comment" + ], + "discord": [ + "message", + "replied", + "replied_thread", + "joined_guild", + "left_guild" + ], + "slack": [ + "message", + "replied", + "replied_thread", + "file_share", + "reaction_added", + "channel_joined", + "left_channel" + ], + "twitter": [ + "mention", + "follow", + "hashtag" + ], + "devto": [ + "commented", + "post" + ] +} \ No newline at end of file diff --git a/frontend/src/modules/activity/components/activity-autocomplete-input.vue b/frontend/src/modules/activity/components/activity-autocomplete-input.vue index c99ca4f36c..ffecdef76a 100644 --- a/frontend/src/modules/activity/components/activity-autocomplete-input.vue +++ b/frontend/src/modules/activity/components/activity-autocomplete-input.vue @@ -41,7 +41,7 @@ export default { 'app-activity-form-modal': ActivityFormModal }, props: { - value: { + modelValue: { type: Object, default: () => {} }, @@ -82,7 +82,7 @@ export default { model: { get: function () { - return this.value + return this.modelValue }, set: function (value) { diff --git a/frontend/src/modules/activity/components/activity-dropdown.vue b/frontend/src/modules/activity/components/activity-dropdown.vue index 36ab310ace..065f63d3bd 100644 --- a/frontend/src/modules/activity/components/activity-dropdown.vue +++ b/frontend/src/modules/activity/components/activity-dropdown.vue @@ -25,6 +25,7 @@ title="Edit Activity" :append-to-body="true" :destroy-on-close="true" + :close-on-click-modal="false" custom-class="el-dialog--lg" @close="editing = false" > diff --git a/frontend/src/modules/activity/components/activity-form-modal.vue b/frontend/src/modules/activity/components/activity-form-modal.vue index 4c691fdf42..40cd66d180 100644 --- a/frontend/src/modules/activity/components/activity-form-modal.vue +++ b/frontend/src/modules/activity/components/activity-form-modal.vue @@ -3,6 +3,7 @@ { + return { + records: {}, + rows: [], + count: 0, + loading: { + table: false, + view: false, + form: false, + submit: false + }, + filter: {}, + rawFilter: {}, + pagination: {}, + sorter: {}, + table: null, + form: null + } + }, + + getters: { + loading: (state) => (component) => + state.loading[component], + + find: (state) => (id) => { + return state.records[id] + }, + + records: (state) => { + return state.records + }, + + rows: (state) => { + return state.rows.map((id) => state.records[id]) + }, + + activeRows: (state, getters) => { + return getters.rows.filter((c) => c.active) + }, + + count: (state) => state.count, + + hasRows: (state, getters) => getters.count > 0, + + orderBy: (state) => { + const sorter = state.sorter + + if (!sorter) { + return null + } + + if (!sorter.prop) { + return null + } + + let direction = + sorter.order === 'descending' ? 'DESC' : 'ASC' + + return `${sorter.prop}_${direction}` + }, + + filter: (state) => state.filter, + + rawFilter: (state) => state.rawFilter, + + limit: (state) => { + const pagination = state.pagination + + if (!pagination || !pagination.pageSize) { + return INITIAL_PAGE_SIZE + } + + return pagination.pageSize + }, + + offset: (state) => { + const pagination = state.pagination + + if (!pagination || !pagination.pageSize) { + return 0 + } + + const currentPage = pagination.currentPage || 1 + + return (currentPage - 1) * pagination.pageSize + }, + + pagination: (state, getters) => { + return { + ...state.pagination, + total: getters.count, + showSizeChanger: true + } + }, + + selectedRows: (state) => { + return state.table + ? state.table.getSelectionRows() + : [] + }, + + form: (state) => state.form + }, + + mutations: { + RESETED(state) { + state.rows = [] + state.count = 0 + state.loading.table = false + state.filter = {} + state.rawFilter = {} + state.pagination = {} + state.sorter = {} + if (state.table) { + state.table.clearSelection() + } + }, + + UNSELECT_ALL(state) { + if (state.table) { + state.table.clearSelection() + } + }, + + TABLE_MOUNTED(state, payload) { + state.table = payload + }, + + PAGINATION_CHANGED(state, payload) { + state.pagination = payload || {} + }, + + PAGINATION_CURRENT_PAGE_CHANGED(state, payload) { + const previousPagination = state.pagination || {} + + state.pagination = { + currentPage: payload || 1, + pageSize: + previousPagination.pageSize || INITIAL_PAGE_SIZE + } + }, + + PAGINATION_PAGE_SIZE_CHANGED(state, payload) { + const previousPagination = state.pagination || {} + + state.pagination = { + currentPage: previousPagination.currentPage || 1, + pageSize: payload || INITIAL_PAGE_SIZE + } + }, + + SORTER_CHANGED(state, payload) { + state.sorter = payload || {} + }, + + FETCH_STARTED(state, payload) { + state.loading.table = true + + if (state.table) { + state.table.clearSelection() + } + + state.rawFilter = + payload && state.rawFilter ? state.rawFilter : {} + state.filter = + payload && payload.filter ? payload.filter : {} + state.pagination = + payload && payload.keepPagination + ? state.pagination + : { + pageSize: + state.pagination && + state.pagination.pageSize + } + }, + + FETCH_SUCCESS(state, payload) { + state.loading.table = false + for (let automation of payload.rows) { + state.records[automation.id] = automation + } + state.rows = payload.rows.map((row) => row.id) + state.count = payload.count + }, + + FETCH_ERROR(state) { + state.loading.table = false + state.rows = [] + state.count = 0 + }, + + FIND_STARTED(state) { + state.loading.view = true + }, + + FIND_SUCCESS(state, record) { + state.loading.view = false + state.records[record.id] = record + }, + + FIND_ERROR(state) { + state.loading.view = false + }, + + INIT_FORM_STARTED(state) { + state.form = null + state.loading.form = true + }, + + INIT_FORM_SUCCESS(state, payload) { + state.form = payload + state.loading.form = false + }, + + INIT_FORM_ERROR(state) { + state.form = null + state.loading.form = false + }, + + CREATE_STARTED(state) { + state.loading.submit = true + }, + + CREATE_SUCCESS(state, record) { + state.loading.submit = false + state.records[record.id] = record + if (state.rows.indexOf(record.id) === -1) { + state.rows.push(record.id) + } + state.count++ + }, + + CREATE_ERROR(state) { + state.loading.submit = false + }, + + UPDATE_STARTED(state) { + state.loading.submit = true + }, + + UPDATE_SUCCESS(state, record) { + state.loading.submit = false + state.records[record.id] = record + }, + + UPDATE_ERROR(state) { + state.loading.submit = false + }, + + DESTROY_STARTED(state) { + state.loading.submit = true + }, + + DESTROY_SUCCESS(state, automationId) { + state.loading.submit = false + const index = state.rows.indexOf(automationId) + state.rows.splice(index, 1) + delete state.records[automationId] + }, + + DESTROY_ERROR(state) { + state.loading.submit = false + }, + + DESTROY_ALL_STARTED(state) { + state.loading.submit = true + }, + + DESTROY_ALL_SUCCESS(state, automationIds) { + state.loading.submit = false + + for (const automationId of automationIds) { + const index = state.rows.indexOf(automationId) + state.rows.splice(index, 1) + delete state.records[automationId] + } + }, + + DESTROY_ALL_ERROR(state) { + state.loading.submit = false + }, + + PUBLISH_ALL_STARTED(state) { + state.loading.submit = true + }, + + PUBLISH_ALL_SUCCESS(state, automationIds) { + state.loading.submit = false + + for (const automationId of automationIds) { + state.records[automationId].active = true + } + }, + + PUBLISH_ALL_ERROR(state) { + state.loading.submit = false + }, + + UNPUBLISH_ALL_STARTED(state) { + state.loading.submit = true + }, + + UNPUBLISH_ALL_SUCCESS(state, automationIds) { + state.loading.submit = false + + for (const automationId of automationIds) { + state.records[automationId].active = false + } + }, + + UNPUBLISH_ALL_ERROR(state) { + state.loading.submit = false + } + }, + + actions: { + doUnselectAll({ commit }) { + commit('UNSELECT_ALL') + }, + + doMountTable({ commit }, table) { + commit('TABLE_MOUNTED', table) + }, + + async doResetStore({ commit }) { + commit('RESETED') + }, + async doReset({ commit, getters, dispatch }) { + commit('RESETED') + return dispatch('doFetch', { + filter: getters.filter, + rawFilter: getters.rawFilter + }) + }, + doChangePagination( + { commit, getters, dispatch }, + pagination + ) { + commit('PAGINATION_CHANGED', pagination) + const filter = getters.filter + const rawFilter = getters.rawFilter + dispatch('doFetch', { + filter, + rawFilter, + keepPagination: true + }) + }, + + doChangePaginationPageSize( + { commit, getters, dispatch }, + pageSize + ) { + commit('PAGINATION_PAGE_SIZE_CHANGED', pageSize) + const filter = getters.filter + const rawFilter = getters.rawFilter + dispatch('doFetch', { + filter, + rawFilter, + keepPagination: true + }) + }, + + doChangePaginationCurrentPage( + { commit, getters, dispatch }, + currentPage + ) { + commit('PAGINATION_CURRENT_PAGE_CHANGED', currentPage) + const filter = getters.filter + const rawFilter = getters.rawFilter + dispatch('doFetch', { + filter, + rawFilter, + keepPagination: true + }) + }, + + doChangeSort({ commit, getters, dispatch }, sorter) { + commit('SORTER_CHANGED', sorter) + const filter = getters.filter + const rawFilter = getters.rawFilter + dispatch('doFetch', { + filter, + rawFilter, + keepPagination: true + }) + }, + + async doFetch( + { commit, getters }, + { + filter = null, + rawFilter = null, + keepPagination = false + } + ) { + try { + commit('FETCH_STARTED', { + filter, + rawFilter, + keepPagination + }) + + const response = await AutomationService.list( + filter, + getters.orderBy, + getters.limit, + getters.offset + ) + + commit('FETCH_SUCCESS', { + rows: response.rows, + count: response.count + }) + } catch (error) { + Errors.handle(error) + commit('FETCH_ERROR') + } + }, + async doCreate({ commit }, automation) { + try { + commit('CREATE_STARTED') + + const response = await AutomationService.create( + automation + ) + + commit('CREATE_SUCCESS', response) + Message.success('Automation created successfully') + } catch (error) { + Errors.handle(error) + commit('FETCH_ERROR') + } + }, + + async doDestroy( + { commit, dispatch, rootGetters }, + automationId + ) { + try { + commit('DESTROY_STARTED') + + await AutomationService.destroy(automationId) + + commit('DESTROY_SUCCESS', automationId) + + dispatch( + `automation/doFetch`, + rootGetters[`automation/filter`], + { + root: true + } + ) + Message.success('Automation deleted successfully') + } catch (error) { + Errors.handle(error) + commit('DESTROY_ERROR') + } + }, + + async doDestroyAll( + { commit, dispatch, rootGetters }, + automationIds + ) { + try { + commit('DESTROY_ALL_STARTED') + + await AutomationService.destroyAll(automationIds) + + commit('DESTROY_ALL_SUCCESS', automationIds) + + dispatch( + `automation/doFetch`, + rootGetters[`automation/filter`], + { + root: true + } + ) + Message.success( + `Automation${ + automationIds.length > 1 ? 's' : '' + } deleted successfully` + ) + } catch (error) { + Errors.handle(error) + commit('DESTROY_ALL_ERROR') + } + }, + + async doPublishAll( + { commit, dispatch, rootGetters }, + automationIds + ) { + try { + commit('PUBLISH_ALL_STARTED') + + await AutomationService.publishAll(automationIds) + + commit('PUBLISH_ALL_SUCCESS', automationIds) + + dispatch( + `automation/doFetch`, + rootGetters[`automation/filter`], + { + root: true + } + ) + Message.success( + `Automation${ + automationIds.length > 1 ? 's' : '' + } published successfully` + ) + } catch (error) { + Errors.handle(error) + commit('PUBLISH_ALL_ERROR') + } + }, + + async doUnpublishAll( + { commit, dispatch, rootGetters }, + automationIds + ) { + try { + commit('UNPUBLISH_ALL_STARTED') + + await AutomationService.unpublishAll(automationIds) + + commit('UNPUBLISH_ALL_SUCCESS', automationIds) + + Message.success( + 'Automations unpublished successfully' + ) + dispatch( + `automation/doFetch`, + rootGetters[`automation/filter`], + { + root: true + } + ) + Message.success( + `Automation${ + automationIds.length > 1 ? 's' : '' + } unpublished successfully` + ) + } catch (error) { + Errors.handle(error) + commit('UNPUBLISH_ALL_ERROR') + } + }, + + async doFind({ commit }, id) { + try { + commit('FIND_STARTED', id) + const record = await AutomationService.find(id) + commit('FIND_SUCCESS', record) + } catch (error) { + Errors.handle(error) + commit('FIND_ERROR', id) + } + }, + + async doInitForm({ commit, getters }, id) { + try { + commit('INIT_FORM_STARTED') + + let record = null + + if (id) { + record = getters.find(id) + } + + commit('INIT_FORM_SUCCESS', record) + } catch (error) { + Errors.handle(error) + commit('INIT_FORM_ERROR') + } + }, + + async doUpdate( + { commit, getters, dispatch }, + { id, values } + ) { + try { + commit('UPDATE_STARTED', id) + const record = await AutomationService.update( + id, + values + ) + dispatch('doFetch', { + filter: getters.filter, + rawFilter: getters.rawFilter + }) + commit('UPDATE_SUCCESS', record) + Message.success('Automation updated successfully') + } catch (error) { + Errors.handle(error) + commit('UPDATE_ERROR', id) + } + }, + + async doPublish({ commit, getters, dispatch }, { id }) { + try { + commit('UPDATE_STARTED', id) + const record = await AutomationService.update(id, { + state: 'active' + }) + dispatch('doFetch', { + filter: getters.filter, + rawFilter: getters.rawFilter + }) + commit('UPDATE_SUCCESS', record) + Message.success('Automation published successfully') + } catch (error) { + Errors.handle(error) + commit('UPDATE_ERROR', id) + } + }, + + async doUnpublish( + { commit, getters, dispatch }, + { id } + ) { + try { + commit('UPDATE_STARTED', id) + const record = await AutomationService.update(id, { + state: 'disabled' + }) + dispatch('doFetch', { + filter: getters.filter, + rawFilter: getters.rawFilter + }) + commit('UPDATE_SUCCESS', record) + Message.success( + 'Automation unpublished successfully' + ) + } catch (error) { + Errors.handle(error) + commit('UPDATE_ERROR', id) + } + } + } +} diff --git a/frontend/src/modules/automation/components/automation-dropdown.vue b/frontend/src/modules/automation/components/automation-dropdown.vue new file mode 100644 index 0000000000..9bef8477c5 --- /dev/null +++ b/frontend/src/modules/automation/components/automation-dropdown.vue @@ -0,0 +1,142 @@ + + + diff --git a/frontend/src/modules/automation/components/automation-list-page.vue b/frontend/src/modules/automation/components/automation-list-page.vue new file mode 100644 index 0000000000..ca1062f0c4 --- /dev/null +++ b/frontend/src/modules/automation/components/automation-list-page.vue @@ -0,0 +1,112 @@ + + + diff --git a/frontend/src/modules/automation/components/automation-list-table.vue b/frontend/src/modules/automation/components/automation-list-table.vue new file mode 100644 index 0000000000..d41c5c04f0 --- /dev/null +++ b/frontend/src/modules/automation/components/automation-list-table.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/frontend/src/modules/automation/components/automation-toggle.vue b/frontend/src/modules/automation/components/automation-toggle.vue new file mode 100644 index 0000000000..1620c963e8 --- /dev/null +++ b/frontend/src/modules/automation/components/automation-toggle.vue @@ -0,0 +1,41 @@ + + + diff --git a/frontend/src/modules/automation/components/webhooks/webhook-execution-list.vue b/frontend/src/modules/automation/components/webhooks/webhook-execution-list.vue new file mode 100644 index 0000000000..42bb4f2e0e --- /dev/null +++ b/frontend/src/modules/automation/components/webhooks/webhook-execution-list.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/frontend/src/modules/automation/components/webhooks/webhook-execution.vue b/frontend/src/modules/automation/components/webhooks/webhook-execution.vue new file mode 100644 index 0000000000..55618a8ce9 --- /dev/null +++ b/frontend/src/modules/automation/components/webhooks/webhook-execution.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/frontend/src/modules/automation/components/webhooks/webhook-form.vue b/frontend/src/modules/automation/components/webhooks/webhook-form.vue new file mode 100644 index 0000000000..8fb7775455 --- /dev/null +++ b/frontend/src/modules/automation/components/webhooks/webhook-form.vue @@ -0,0 +1,374 @@ + + + + + diff --git a/frontend/src/modules/community-member/components/community-member-dropdown.vue b/frontend/src/modules/community-member/components/community-member-dropdown.vue index f05548da33..74103683d9 100644 --- a/frontend/src/modules/community-member/components/community-member-dropdown.vue +++ b/frontend/src/modules/community-member/components/community-member-dropdown.vue @@ -59,6 +59,7 @@ v-model="editing" title="Edit Member" :append-to-body="true" + :close-on-click-modal="false" :destroy-on-close="true" custom-class="el-dialog--lg" @close="editing = false" diff --git a/frontend/src/modules/community-member/components/community-member-form-modal.vue b/frontend/src/modules/community-member/components/community-member-form-modal.vue index c2637f3ff3..8ebc56edae 100644 --- a/frontend/src/modules/community-member/components/community-member-form-modal.vue +++ b/frontend/src/modules/community-member/components/community-member-form-modal.vue @@ -2,6 +2,7 @@
diff --git a/frontend/src/modules/community-member/components/community-member-list-filter.vue b/frontend/src/modules/community-member/components/community-member-list-filter.vue index 9090d63535..fbcea21d95 100644 --- a/frontend/src/modules/community-member/components/community-member-list-filter.vue +++ b/frontend/src/modules/community-member/components/community-member-list-filter.vue @@ -13,6 +13,7 @@ diff --git a/frontend/src/modules/conversation/components/conversation-list-filter.vue b/frontend/src/modules/conversation/components/conversation-list-filter.vue index 6d9a4d3018..84541be03f 100644 --- a/frontend/src/modules/conversation/components/conversation-list-filter.vue +++ b/frontend/src/modules/conversation/components/conversation-list-filter.vue @@ -12,6 +12,7 @@ diff --git a/frontend/src/modules/index.js b/frontend/src/modules/index.js index de9f3eeb3a..832460d900 100644 --- a/frontend/src/modules/index.js +++ b/frontend/src/modules/index.js @@ -16,6 +16,7 @@ import widget from '@/modules/widget/widget-module' import report from '@/modules/report/report-module' import conversation from '@/modules/conversation/conversation-module' import eagleEye from '@/premium/eagle-eye/eagle-eye-module' +import automation from '@/modules/automation/automation-module' const modules = { shared, @@ -35,7 +36,8 @@ const modules = { widget, report, conversation, - eagleEye + eagleEye, + automation } export default modules diff --git a/frontend/src/modules/integration/components/devto-integration-widget.vue b/frontend/src/modules/integration/components/devto-integration-widget.vue index 6413b1b4e4..3f7ff7b18f 100644 --- a/frontend/src/modules/integration/components/devto-integration-widget.vue +++ b/frontend/src/modules/integration/components/devto-integration-widget.vue @@ -2,6 +2,7 @@ @@ -72,7 +63,7 @@ import { mapGetters, mapActions } from 'vuex' import { i18n } from '@/i18n' export default { - name: 'AppMenuUserDropdown', + name: 'AppAccountDropdown', props: { collapsed: { diff --git a/frontend/src/modules/layout/components/menu.vue b/frontend/src/modules/layout/components/menu.vue index 6efb0a8b27..e7c6ae31ad 100644 --- a/frontend/src/modules/layout/components/menu.vue +++ b/frontend/src/modules/layout/components/menu.vue @@ -241,7 +241,7 @@ - + - + - + - + + + + -
+
@@ -118,6 +131,7 @@ + + diff --git a/frontend/src/modules/widget/components/dashboard/widget-benchmark.vue b/frontend/src/modules/widget/components/dashboard/widget-benchmark.vue index 494b4a7534..617ec24745 100644 --- a/frontend/src/modules/widget/components/dashboard/widget-benchmark.vue +++ b/frontend/src/modules/widget/components/dashboard/widget-benchmark.vue @@ -34,6 +34,7 @@ diff --git a/frontend/src/plugins/confirm.js b/frontend/src/plugins/confirm.js index a77f082fd4..f81ad912a8 100644 --- a/frontend/src/plugins/confirm.js +++ b/frontend/src/plugins/confirm.js @@ -14,7 +14,7 @@ export default { ) => { Object.assign(options, { customClass: 'confirm', - cancelButtonClass: 'btn btn--secondary', + cancelButtonClass: 'btn btn--secondary mr-2', confirmButtonClass: 'btn btn--primary' }) diff --git a/frontend/src/premium/eagle-eye/components/eagle-eye-filter.vue b/frontend/src/premium/eagle-eye/components/eagle-eye-filter.vue index 71c198a48a..47ac3f1c00 100644 --- a/frontend/src/premium/eagle-eye/components/eagle-eye-filter.vue +++ b/frontend/src/premium/eagle-eye/components/eagle-eye-filter.vue @@ -5,7 +5,11 @@ :expanded="expanded" @click="expanded = true" > - + diff --git a/frontend/src/premium/eagle-eye/components/eagle-eye-header.vue b/frontend/src/premium/eagle-eye/components/eagle-eye-header.vue index b149af8feb..957148a32f 100644 --- a/frontend/src/premium/eagle-eye/components/eagle-eye-header.vue +++ b/frontend/src/premium/eagle-eye/components/eagle-eye-header.vue @@ -74,6 +74,10 @@ export default { &-tabs { @apply mt-8; + + .el-tabs__nav-wrap::after { + @apply hidden; + } } } diff --git a/frontend/src/premium/user/components/user-dropdown.vue b/frontend/src/premium/user/components/user-dropdown.vue index faa6bb3803..832e753737 100644 --- a/frontend/src/premium/user/components/user-dropdown.vue +++ b/frontend/src/premium/user/components/user-dropdown.vue @@ -29,6 +29,7 @@ diff --git a/frontend/src/premium/user/components/user-list-page.vue b/frontend/src/premium/user/components/user-list-page.vue index 6ff1f54416..391acfceab 100644 --- a/frontend/src/premium/user/components/user-list-page.vue +++ b/frontend/src/premium/user/components/user-list-page.vue @@ -14,6 +14,7 @@ - +