diff --git a/.gitignore b/.gitignore index 9b2ed79f9..3e33d8a2b 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,7 @@ vitest-coverage/ *.vitest.log test-output.txt server/job/test/fixtures + +.github +_reference/ragmate/.ragmate.env +docker_data diff --git a/.platform/nginx/conf.d/proxy.conf b/.platform/nginx/conf.d/proxy.conf index 2dc60b344..1ab7fa483 100644 --- a/.platform/nginx/conf.d/proxy.conf +++ b/.platform/nginx/conf.d/proxy.conf @@ -1,2 +1,2 @@ client_max_body_size 50M; -client_body_buffer_size 5M; +client_body_buffer_size 5M; \ No newline at end of file diff --git a/_reference/localEmailViewer/package-lock.json b/_reference/localEmailViewer/package-lock.json index d8d53da2e..ae14abb19 100644 --- a/_reference/localEmailViewer/package-lock.json +++ b/_reference/localEmailViewer/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "express": "^4.21.1", - "mailparser": "^3.7.1", + "express": "^5.1.0", + "mailparser": "^3.7.2", "node-fetch": "^3.3.2" } }, @@ -28,46 +28,36 @@ } }, "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { "node": ">= 0.6" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=18" } }, "node_modules/bytes": { @@ -79,17 +69,27 @@ "node": ">= 0.8" } }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -99,9 +99,9 @@ } }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" @@ -129,10 +129,13 @@ } }, "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "license": "MIT" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", @@ -144,12 +147,20 @@ } }, "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "license": "MIT", "dependencies": { - "ms": "2.0.0" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/deepmerge": { @@ -161,23 +172,6 @@ "node": ">=0.10.0" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -187,16 +181,6 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -252,6 +236,20 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -268,9 +266,9 @@ } }, "node_modules/encoding-japanese": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.1.0.tgz", - "integrity": "sha512-58XySVxUgVlBikBTbQ8WdDxBDHIdXucB16LO5PBHR8t75D54wQrNo4cg+58+R1CtJfKnsVsvt9XlteRaR8xw1w==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", "license": "MIT", "engines": { "node": ">=8.10.0" @@ -289,13 +287,10 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, "engines": { "node": ">= 0.4" } @@ -309,6 +304,18 @@ "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -325,45 +332,45 @@ } }, "node_modules/express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { - "node": ">= 0.10.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/fetch-blob": { @@ -390,18 +397,17 @@ } }, "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" }, "engines": { "node": ">= 0.8" @@ -429,12 +435,12 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/function-bind": { @@ -447,16 +453,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -465,34 +476,23 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gopd": { + "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { - "get-intrinsic": "^1.1.3" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">= 0.4" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -502,9 +502,9 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -586,12 +586,12 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" @@ -612,6 +612,12 @@ "node": ">= 0.10" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/leac": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", @@ -628,33 +634,21 @@ "license": "MIT" }, "node_modules/libmime": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.5.tgz", - "integrity": "sha512-nSlR1yRZ43L3cZCiWEw7ali3jY29Hz9CQQ96Oy+sSspYnIP5N54ucOPHqooBsXzwrX1pwn13VUE05q4WmzfaLg==", + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.6.tgz", + "integrity": "sha512-j9mBC7eiqi6fgBPAGvKCXJKJSIASanYF4EeA4iBzSG0HxQxmXnR3KbyWqTn4CwsKSebqCv2f5XZfAO6sKzgvwA==", "license": "MIT", "dependencies": { - "encoding-japanese": "2.1.0", + "encoding-japanese": "2.2.0", "iconv-lite": "0.6.3", "libbase64": "1.3.0", - "libqp": "2.1.0" - } - }, - "node_modules/libmime/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" + "libqp": "2.1.1" } }, "node_modules/libqp": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.0.tgz", - "integrity": "sha512-O6O6/fsG5jiUVbvdgT7YX3xY3uIadR6wEZ7+vy9u7PKHAlSEB6blvC1o5pHBjgsi95Uo0aiBBdkyFecj6jtb7A==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", "license": "MIT" }, "node_modules/linkify-it": { @@ -667,161 +661,95 @@ } }, "node_modules/mailparser": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.1.tgz", - "integrity": "sha512-RCnBhy5q8XtB3mXzxcAfT1huNqN93HTYYyL6XawlIKycfxM/rXPg9tXoZ7D46+SgCS1zxKzw+BayDQSvncSTTw==", + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.2.tgz", + "integrity": "sha512-iI0p2TCcIodR1qGiRoDBBwboSSff50vQAWytM5JRggLfABa4hHYCf3YVujtuzV454xrOP352VsAPIzviqMTo4Q==", "license": "MIT", "dependencies": { - "encoding-japanese": "2.1.0", + "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", "iconv-lite": "0.6.3", - "libmime": "5.3.5", + "libmime": "5.3.6", "linkify-it": "5.0.0", - "mailsplit": "5.4.0", - "nodemailer": "6.9.13", + "mailsplit": "5.4.2", + "nodemailer": "6.9.16", "punycode.js": "2.3.1", - "tlds": "1.252.0" - } - }, - "node_modules/mailparser/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" + "tlds": "1.255.0" } }, "node_modules/mailsplit": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.0.tgz", - "integrity": "sha512-wnYxX5D5qymGIPYLwnp6h8n1+6P6vz/MJn5AzGjZ8pwICWssL+CCQjWBIToOVHASmATot4ktvlLo6CyLfOXWYA==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.2.tgz", + "integrity": "sha512-4cczG/3Iu3pyl8JgQ76dKkisurZTmxMrA4dj/e8d2jKYcFTZ7MxOzg1gTioTDMPuFXwTrVuN/gxhkrO7wLg7qA==", "license": "(MIT OR EUPL-1.1+)", "dependencies": { - "libbase64": "1.2.1", - "libmime": "5.2.0", - "libqp": "2.0.1" + "libbase64": "1.3.0", + "libmime": "5.3.6", + "libqp": "2.1.1" } }, - "node_modules/mailsplit/node_modules/encoding-japanese": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.0.0.tgz", - "integrity": "sha512-++P0RhebUC8MJAwJOsT93dT+5oc5oPImp1HubZpAuCZ5kTLnhuuBhKHj2jJeO/Gj93idPBWmIuQ9QWMe5rX3pQ==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { - "node": ">=8.10.0" + "node": ">= 0.4" } }, - "node_modules/mailsplit/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/mailsplit/node_modules/libbase64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.2.1.tgz", - "integrity": "sha512-l+nePcPbIG1fNlqMzrh68MLkX/gTxk/+vdvAb388Ssi7UuUN31MI44w4Yf33mM3Cm4xDfw48mdf3rkdHszLNew==", - "license": "MIT" - }, - "node_modules/mailsplit/node_modules/libmime": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.2.0.tgz", - "integrity": "sha512-X2U5Wx0YmK0rXFbk67ASMeqYIkZ6E5vY7pNWRKtnNzqjvdYYG8xtPDpCnuUEnPU9vlgNev+JoSrcaKSUaNvfsw==", - "license": "MIT", - "dependencies": { - "encoding-japanese": "2.0.0", - "iconv-lite": "0.6.3", - "libbase64": "1.2.1", - "libqp": "2.0.1" - } - }, - "node_modules/mailsplit/node_modules/libqp": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.0.1.tgz", - "integrity": "sha512-Ka0eC5LkF3IPNQHJmYBWljJsw0UvM6j+QdKRbWyCdTmYwvIDE6a7bCm0UkTAL/K+3KXK5qXT/ClcInU01OpdLg==", - "license": "MIT" - }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", + "engines": { + "node": ">=18" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.54.0" }, "engines": { "node": ">= 0.6" } }, "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -865,18 +793,18 @@ } }, "node_modules/nodemailer": { - "version": "6.9.13", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz", - "integrity": "sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA==", + "version": "6.9.16", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz", + "integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" } }, "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -897,6 +825,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseley": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", @@ -920,10 +857,13 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", - "license": "MIT" + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } }, "node_modules/peberminta": { "version": "0.9.0", @@ -957,12 +897,12 @@ } }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -981,20 +921,36 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", - "iconv-lite": "0.4.24", + "iconv-lite": "0.6.3", "unpipe": "1.0.0" }, "engines": { "node": ">= 0.8" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1034,74 +990,40 @@ } }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" } }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "node": ">= 18" } }, "node_modules/setprototypeof": { @@ -1111,15 +1033,69 @@ "license": "ISC" }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -1138,9 +1114,9 @@ } }, "node_modules/tlds": { - "version": "1.252.0", - "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.252.0.tgz", - "integrity": "sha512-GA16+8HXvqtfEnw/DTcwB0UU354QE1n3+wh08oFjr6Znl7ZLAeUgYzCcK+/CCrOyE0vnHR8/pu3XXG3vDijXpQ==", + "version": "1.255.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.255.0.tgz", + "integrity": "sha512-tcwMRIioTcF/FcxLev8MJWxCp+GUALRhFEqbDoZrnowmKSGqPrl5pqS+Sut2m8BgJ6S4FExCSSpGffZ0Tks6Aw==", "license": "MIT", "bin": { "tlds": "bin.js" @@ -1156,13 +1132,14 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { "node": ">= 0.6" @@ -1183,15 +1160,6 @@ "node": ">= 0.8" } }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1209,6 +1177,12 @@ "engines": { "node": ">= 8" } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" } } } diff --git a/_reference/localEmailViewer/package.json b/_reference/localEmailViewer/package.json index ad795b60f..74cef99ff 100644 --- a/_reference/localEmailViewer/package.json +++ b/_reference/localEmailViewer/package.json @@ -11,8 +11,8 @@ "license": "ISC", "description": "", "dependencies": { - "express": "^4.21.1", - "mailparser": "^3.7.1", + "express": "^5.1.0", + "mailparser": "^3.7.2", "node-fetch": "^3.3.2" } } diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel index d92f00f41..36ec1a0c9 100644 --- a/bodyshop_translations.babel +++ b/bodyshop_translations.babel @@ -1,4 +1,4 @@ - + + ${ + strings.header && + ` + + +
+ +
+
${strings.header}
+
+ ` + } + ${ + strings.subHeader && + ` + + +
+ +
+

${strings.subHeader}

+
+ ` + } - - - - - - - -
- - - - - - -
${strings.body}
-
- - - - - - - - - - -` + - end + ${ + strings.body && + ` + + + +
+ +
+ ${strings.body} +
+ + ` + } + ` + + end(strings.dateLine) ); }; diff --git a/server/email/html.js b/server/email/html.js index ec96c6ff9..79f8a9fa9 100644 --- a/server/email/html.js +++ b/server/email/html.js @@ -1,2611 +1,172 @@ -const header = ` +const InstanceManager = require("../utils/instanceMgr").default; + +const header = ` + + - - + `; +const start = ` + +
+ You have received a message from ${InstanceManager({imex: "ImEX Online", rome: "Rome Online"})}. Open to see the full contents of this email. + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  +
+ + +
+
+ + +
 
`; -const start = `
 
`; -const end = `
 
`; + +function end(dateLine) { + return ` + + + + + + + + + +
 
+
+
+ + +`}; module.exports = { header, diff --git a/server/email/sendemail.js b/server/email/sendemail.js index 26d5c8560..0c6ce4015 100644 --- a/server/email/sendemail.js +++ b/server/email/sendemail.js @@ -40,7 +40,9 @@ const logEmail = async (req, email) => { to: req?.body?.to, cc: req?.body?.cc, subject: req?.body?.subject, - email + email, + errorMessage: error?.message, + errorStack: error?.stack // info, }); } @@ -68,6 +70,7 @@ const sendServerEmail = async ({ subject, text }) => { ] } }, + // eslint-disable-next-line no-unused-vars (err, info) => { logger.log("server-email-failure", err ? "error" : "debug", null, null, { message: err?.message, @@ -80,6 +83,108 @@ const sendServerEmail = async ({ subject, text }) => { } }; +const sendWelcomeEmail = async ({ to, resetLink, dateLine, features, bcc }) => { + try { + await mailer.sendMail({ + from: InstanceManager({ + imex: `ImEX Online `, + rome: `Rome Online ` + }), + to, + bcc, + subject: InstanceManager({ + imex: "Welcome to the ImEX Online platform.", + rome: "Welcome to the Rome Online platform." + }), + html: generateEmailTemplate({ + header: InstanceManager({ + imex: "Welcome to the ImEX Online platform.", + rome: "Welcome to the Rome Online platform." + }), + subHeader: `Your ${InstanceManager({imex: features?.allAccess ? "ImEX Online": "ImEX Lite", rome: features?.allAccess ? "RO Manager" : "RO Basic"})} shop setup has been completed, and this email will include all the information you need to begin.`, + body: ` +

To finish setting up your account, visit this link and enter your desired password. Reset Password

+ + + + + +
+ +
+

To access your ${InstanceManager({imex: features.allAccess ? "ImEX Online": "ImEX Lite", rome: features.allAccess ? "RO Manager" : "RO Basic"})} shop, visit ${InstanceManager({imex: "imex.online", rome: "romeonline.io"})}. Your username is your email, and your password is what you previously set up. Contact support for additional logins.

+
+ ${InstanceManager({ + rome: ` + + +
+ + +
+

To push estimates over from your estimating system, you must download the Web-Est EMS Unzipper & Rome Online Partner (Computers using Windows only). Here are some steps to help you get started.

+
+ +
+ + +
+ + +
+

Once you successfully set up the partner, now it's time to do some initial in-product items: Please note, an estimate must be exported from the estimating platform to use tours.

+
+ +
+ + +
+ +
+

If you need any assistance with setting up the programs, or if you want a dedicated Q&A session with one of our customer success specialists, schedule by clicking this link - Rome Basic Training Booking

+
+ + +
+ +
+

If you have additional questions or need any support, feel free to use the RO Basic support chat (blue chat box located in the bottom right corner) or give us a call at (410) 357-6700. We are here to help make your experience seamless!

+
+ ` + })} + + +
+ +
+

In addition to the training tour, you can also book a live one-on-one demo to see exactly how our system can help streamline the repair process at your shop, schedule by clicking this link - ${InstanceManager({imex: "ImEX Lite", rome: "Rome Basic"})} Demo Booking

+
+ + +
+ +
+

Thanks,

+
+ + +
+
+

The ${InstanceManager({imex: "ImEX Online", rome: "Rome Online"})} Team

+ `, + dateLine + }) + }); + } catch (error) { + logger.log("server-email-failure", "error", null, null, { error }); + } +}; + const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachments }) => { try { mailer.sendMail( @@ -93,6 +198,7 @@ const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachmen ...(type === "text" ? { text } : { html }), attachments: attachments || null }, + // eslint-disable-next-line no-unused-vars (err, info) => { // (message, type, user, record, meta logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message, stack: err?.stack }); @@ -143,22 +249,20 @@ const sendEmail = async (req, res) => { to: req.body.to, cc: req.body.cc, subject: req.body.subject, - attachments: - [ - ...((req.body.attachments && - req.body.attachments.map((a) => { - return { - filename: a.filename, - path: a.path - }; - })) || - []), - ...downloadedMedia.map((a) => { + attachments: [ + ...(req.body.attachments && + req.body.attachments.map((a) => { return { - path: a + filename: a.filename, + path: a.path }; - }) - ] || null, + })), + ...downloadedMedia.map((a) => { + return { + path: a + }; + }) + ], html: isObject(req.body?.templateStrings) ? generateEmailTemplate(req.body.templateStrings) : req.body.html, ses: { // optional extra arguments for SendRawEmail @@ -273,6 +377,7 @@ ${body.bounce?.bouncedRecipients.map( )} ` }, + // eslint-disable-next-line no-unused-vars (err, info) => { logger.log("sns-error", err ? "error" : "debug", "api", null, { errorMessage: err?.message, @@ -294,5 +399,6 @@ module.exports = { sendEmail, sendServerEmail, sendTaskEmail, - emailBounce + emailBounce, + sendWelcomeEmail }; diff --git a/server/email/tasksEmails.js b/server/email/tasksEmails.js index b8c3c42a3..2f434b0bc 100644 --- a/server/email/tasksEmails.js +++ b/server/email/tasksEmails.js @@ -17,11 +17,13 @@ const { formatTaskPriority } = require("../notifications/stringHelpers"); const tasksEmailQueue = taskEmailQueue(); // Cleanup function for the Tasks Email Queue +// eslint-disable-next-line no-unused-vars const tasksEmailQueueCleanup = async () => { try { // Example async operation // console.log("Performing Tasks Email Reminder process cleanup..."); await new Promise((resolve) => tasksEmailQueue.destroy(() => resolve())); + // eslint-disable-next-line no-unused-vars } catch (err) { // console.error("Tasks Email Reminder process cleanup failed:", err); } @@ -254,10 +256,15 @@ const tasksRemindEmail = async (req, res) => { header: `${allTasks.length} Tasks require your attention`, subHeader: `Please click on the Tasks below to view the Task.`, dateLine, - body: `
    + body: ` + ` diff --git a/server/firebase/firebase-handler.js b/server/firebase/firebase-handler.js index c60a04fab..83c6082eb 100644 --- a/server/firebase/firebase-handler.js +++ b/server/firebase/firebase-handler.js @@ -1,14 +1,10 @@ -const path = require("path"); -require("dotenv").config({ - path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) -}); - -const admin = require("firebase-admin"); -const logger = require("../utils/logger"); -//const { sendProManagerWelcomeEmail } = require("../email/sendemail"); -const client = require("../graphql-client/graphql-client").client; const serviceAccount = require(process.env.FIREBASE_ADMINSDK_JSON); -//const generateEmailTemplate = require("../email/generateTemplate"); +const admin = require("firebase-admin"); +const moment = require("moment-timezone"); +const logger = require("../utils/logger"); +const client = require("../graphql-client/graphql-client").client; +const { sendWelcomeEmail } = require("../email/sendemail"); +const { GET_USER_BY_EMAIL } = require("../graphql-client/queries"); admin.initializeApp({ credential: admin.credential.cert(serviceAccount), @@ -201,6 +197,94 @@ const unsubscribe = async (req, res) => { } }; +const getWelcomeEmail = async (req, res) => { + const { authid, email, bcc } = req.body; + + try { + // Fetch user from Firebase + const userRecord = await admin.auth().getUser(authid); + if (!userRecord) { + throw { status: 404, message: "User not found in Firebase." }; + } + + // Fetch user data from the database using GraphQL + const dbUserResult = await client.request(GET_USER_BY_EMAIL, { email: email.toLowerCase() }); + + const dbUser = dbUserResult?.users?.[0]; + if (!dbUser) { + throw { status: 404, message: "User not found in database." }; + } + + // Validate email before proceeding + if (!dbUser.validemail) { + logger.log("admin-send-welcome-email-skip", "debug", req.user.email, null, { + message: "User email is not valid, skipping email.", + email + }); + return res.status(200).json({ message: "User email is not valid, email not sent." }); + } + + // Generate password reset link + const resetLink = await admin.auth().generatePasswordResetLink(dbUser.email); + + // Send welcome email + await sendWelcomeEmail({ + to: dbUser.email, + resetLink, + dateLine: moment().tz(dbUser.associations?.[0]?.bodyshop?.timezone).format("MM/DD/YYYY @ hh:mm a"), + features: dbUser.associations?.[0]?.bodyshop?.features, + bcc + }); + + // Log success and return response + logger.log("admin-send-welcome-email", "debug", req.user.email, null, { + request: req.body, + ioadmin: true, + emailSentTo: email + }); + + return res.status(200).json({ message: "Welcome email sent successfully." }); + } catch (error) { + logger.log("admin-send-welcome-email-error", "ERROR", req.user.email, null, { error }); + + if (!res.headersSent) { + return res.status(error.status || 500).json({ + message: error.message || "Error sending welcome email.", + error + }); + } + } +}; + +const getResetLink = async (req, res) => { + const { authid, email } = req.body; + logger.log("admin-reset-link", "debug", req.user.email, null, { authid, email }); + + try { + // Fetch user from Firebase + const userRecord = await admin.auth().getUser(authid); + if (!userRecord) { + throw { status: 404, message: "User not found in Firebase." }; + } + + // Generate password reset link + const resetLink = await admin.auth().generatePasswordResetLink(email); + + // Log success and return response + logger.log("admin-reset-link-success", "debug", req.user.email, null, { + request: req.body, + ioadmin: true + }); + + return res.status(200).json({ message: "Reset link generated successfully.", resetLink }); + } catch (error) { + return res.status(error.status || 500).json({ + message: error.message || "Error generating reset link.", + error + }); + } +}; + module.exports = { admin, createUser, @@ -208,23 +292,7 @@ module.exports = { getUser, sendNotification, subscribe, - unsubscribe + unsubscribe, + getWelcomeEmail, + getResetLink }; - -//Admin claims code. -// const uid = "JEqqYlsadwPEXIiyRBR55fflfko1"; - -// admin -// .auth() -// .getUser(uid) -// .then((user) => { -// console.log(user); -// admin.auth().setCustomUserClaims(uid, { -// ioadmin: true, -// "https://hasura.io/jwt/claims": { -// "x-hasura-default-role": "debug", -// "x-hasura-allowed-roles": ["admin"], -// "x-hasura-user-id": uid, -// }, -// }); -// }); diff --git a/server/graphql-client/graphql-client.js b/server/graphql-client/graphql-client.js index 069386b73..79d86315b 100644 --- a/server/graphql-client/graphql-client.js +++ b/server/graphql-client/graphql-client.js @@ -1,17 +1,19 @@ const GraphQLClient = require("graphql-request").GraphQLClient; -const path = require("path"); -require("dotenv").config({ - path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) -}); + //New bug introduced with Graphql Request. // https://github.com/prisma-labs/graphql-request/issues/206 // const { Headers } = require("cross-fetch"); // global.Headers = global.Headers || Headers; -exports.client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, { +const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, { headers: { "x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET } }); -exports.unauthclient = new GraphQLClient(process.env.GRAPHQL_ENDPOINT); +const unauthorizedClient = new GraphQLClient(process.env.GRAPHQL_ENDPOINT); + +module.exports = { + client, + unauthorizedClient +}; diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 16c955467..a05dc9538 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -9,6 +9,7 @@ query FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID($mssid: String!, $phone: String!) { } }`; +// Unused exports.GET_JOB_BY_RO_NUMBER = ` query GET_JOB_BY_RO_NUMBER($ro_number: String!) { jobs(where:{ro_number:{_eq:$ro_number}}) { @@ -221,6 +222,7 @@ query QUERY_JOBS_FOR_RECEIVABLES_EXPORT($ids: [uuid!]!) { rate_mash rate_matd class + shopid ca_bc_pvrt ca_customer_gst towing_payable @@ -479,6 +481,7 @@ query QUERY_BILLS_FOR_PAYABLES_EXPORT($bills: [uuid!]!) { ownr_ln ownr_co_nm class + shopid } billlines{ id @@ -529,6 +532,7 @@ exports.QUERY_PAYMENTS_FOR_EXPORT = ` ownr_fn ownr_ln ownr_co_nm + shopid bodyshop { accountingconfig md_responsibility_centers @@ -874,6 +878,43 @@ exports.CHATTER_QUERY = `query CHATTER_EXPORT($start: timestamptz, $bodyshopid: } }`; +exports.CARFAX_QUERY = `query CARFAX_EXPORT($start: timestamptz, $bodyshopid: uuid!, $end: timestamptz) { + bodyshops_by_pk(id: $bodyshopid){ + id + shopname + imexshopid + timezone + } + jobs(where: {_and: [{converted: {_eq: true}}, {v_vin: {_is_null: false}}, {date_invoiced: {_gt: $start}}, {date_invoiced: {_lte: $end}}, {shopid: {_eq: $bodyshopid}}]}) { + id + created_at + ro_number + v_model_yr + v_model_desc + v_make_desc + v_vin + date_estimated + date_open + date_invoiced + loss_date + ins_co_nm + loss_desc + theft_ind + tlos_ind + job_totals + area_of_damage + joblines(where: {removed: {_eq: false}}) { + line_desc + oem_partno + alt_partno + mod_lbr_ty + part_qty + part_type + act_price + } + } +}`; + exports.CLAIMSCORP_QUERY = `query CLAIMSCORP_EXPORT($start: timestamptz, $bodyshopid: uuid!, $end: timestamptz) { bodyshops_by_pk(id: $bodyshopid){ id @@ -1323,6 +1364,27 @@ exports.KAIZEN_QUERY = `query KAIZEN_EXPORT($start: timestamptz, $bodyshopid: uu } }`; +exports.PODIUM_QUERY = `query PODIUM_EXPORT($start: timestamptz, $bodyshopid: uuid!, $end: timestamptz) { + bodyshops_by_pk(id: $bodyshopid){ + id + shopname + podiumid + timezone + } + jobs(where: {_and: [{converted: {_eq: true}}, {actual_delivery: {_gt: $start}}, {actual_delivery: {_lte: $end}}, {shopid: {_eq: $bodyshopid}}, {_or: [{ownr_ph1: {_is_null: false}}, {ownr_ea: {_is_null: false}}]}]}) { + actual_delivery + id + created_at + ro_number + ownr_fn + ownr_ln + ownr_co_nm + ownr_ph1 + ownr_ph2 + ownr_ea + } +}`; + exports.UPDATE_JOB = ` mutation UPDATE_JOB($jobId: uuid!, $job: jobs_set_input!) { update_jobs(where: { id: { _eq: $jobId } }, _set: $job) { @@ -1574,6 +1636,7 @@ query QUERY_JOB_COSTING_DETAILS($id: uuid!) { ca_customer_gst dms_allocation cieca_pfl + cieca_stl materials joblines(where: { removed: { _eq: false } }) { id @@ -1690,6 +1753,7 @@ exports.QUERY_JOB_COSTING_DETAILS_MULTI = ` query QUERY_JOB_COSTING_DETAILS_MULT ca_customer_gst dms_allocation cieca_pfl + cieca_stl materials joblines(where: {removed: {_eq: false}}) { id @@ -1752,6 +1816,7 @@ exports.QUERY_JOB_COSTING_DETAILS_MULTI = ` query QUERY_JOB_COSTING_DETAILS_MULT } }`; +// Exists in Commented out Query exports.INSERT_IOEVENT = ` mutation INSERT_IOEVENT($event: ioevents_insert_input!) { insert_ioevents_one(object: $event) { id @@ -1788,6 +1853,14 @@ exports.GET_CHATTER_SHOPS = `query GET_CHATTER_SHOPS { } }`; +exports.GET_CARFAX_SHOPS = `query GET_CARFAX_SHOPS { + bodyshops{ + id + shopname + imexshopid + } +}`; + exports.GET_CLAIMSCORP_SHOPS = `query GET_CLAIMSCORP_SHOPS { bodyshops(where: {claimscorpid: {_is_null: false}, _or: {claimscorpid: {_neq: ""}}}){ id @@ -1848,6 +1921,16 @@ exports.GET_KAIZEN_SHOPS = `query GET_KAIZEN_SHOPS($imexshopid: [String]) { } }`; +exports.GET_PODIUM_SHOPS = `query GET_PODIUM_SHOPS { + bodyshops(where: {podiumid: {_is_null: false}, _or: {podiumid: {_neq: ""}}}){ + id + shopname + podiumid + imexshopid + timezone + } +}`; + exports.DELETE_ALL_DMS_VEHICLES = `mutation DELETE_ALL_DMS_VEHICLES{ delete_dms_vehicles(where: {}) { affected_rows @@ -2771,6 +2854,7 @@ exports.GET_BODYSHOP_BY_ID = ` imexshopid intellipay_config state + notification_followers } } `; @@ -2807,6 +2891,26 @@ exports.GET_DOCUMENTS_BY_JOB = ` } } }`; +exports.GET_DOCUMENTS_BY_BILL = ` +query GET_DOCUMENTS_BY_BILL($billId: uuid!) { + documents_aggregate(where: {billid: {_eq: $billId}}) { + aggregate { + sum { + size + } + } + } + documents(order_by: {takenat: desc}, where: {billid: {_eq: $billId}}) { + id + name + key + type + size + takenat + extension + } +} +`; exports.QUERY_TEMPORARY_DOCS = ` query QUERY_TEMPORARY_DOCS { documents(where: { jobid: { _is_null: true } }, order_by: { takenat: desc }) { @@ -2853,3 +2957,147 @@ query GET_BODYSHOP_BY_MERCHANTID($merchantID: String!) { email } }`; + +exports.GET_USER_BY_EMAIL = ` +query GET_USER_BY_EMAIL($email: String!) { + users(where: {email: {_eq: $email}}) { + email + validemail + associations { + id + shopid + bodyshop { + id + convenient_company + features + timezone + } + } + } +}`; + +// Define the GraphQL query to get a job by RO number and shop ID +exports.GET_JOB_BY_RO_NUMBER_AND_SHOP_ID = ` + query GET_JOB_BY_RO_NUMBER_AND_SHOP_ID($roNumber: String!, $shopId: uuid!) { + jobs(where: {ro_number: {_eq: $roNumber}, shopid: {_eq: $shopId}}, limit: 1) { + id + shopid + bodyshop { + timezone + } + } + } +`; + +// Define the mutation to insert a new document +exports.INSERT_NEW_DOCUMENT = ` + mutation INSERT_NEW_DOCUMENT($docInput: [documents_insert_input!]!) { + insert_documents(objects: $docInput) { + returning { + id + name + key + } + } + } +`; + +exports.INSERT_JOB_WATCHERS = ` + mutation INSERT_JOB_WATCHERS($watchers: [job_watchers_insert_input!]!) { + insert_job_watchers(objects: $watchers, on_conflict: { constraint: job_watchers_pkey, update_columns: [] }) { + affected_rows + } + } +`; + +exports.GET_NOTIFICATION_WATCHERS = ` + query GET_NOTIFICATION_WATCHERS($shopId: uuid!, $employeeIds: [uuid!]!) { + associations(where: { + _and: [ + { shopid: { _eq: $shopId } }, + { active: { _eq: true } }, + { notifications_autoadd: { _eq: true } } + ] + }) { + id + useremail + } + employees(where: { id: { _in: $employeeIds }, shopid: { _eq: $shopId }, active: { _eq: true } }) { + user_email + } + } +`; + +exports.GET_JOB_WATCHERS_MINIMAL = ` + query GET_JOB_WATCHERS_MINIMAL($jobid: uuid!) { + job_watchers(where: { jobid: { _eq: $jobid } }) { + user_email + user { + authid + } + } + } +`; + +exports.INSERT_INTEGRATION_LOG = ` + mutation INSERT_INTEGRATION_LOG($log: integration_log_insert_input!) { + insert_integration_log_one(object: $log) { + id + } + } +`; + +exports.INSERT_PHONE_NUMBER_OPT_OUT = ` + mutation INSERT_PHONE_NUMBER_OPT_OUT($optOutInput: [phone_number_opt_out_insert_input!]!) { + insert_phone_number_opt_out(objects: $optOutInput, on_conflict: { constraint: phone_number_consent_bodyshopid_phone_number_key, update_columns: [updated_at] }) { + affected_rows + returning { + id + bodyshopid + phone_number + created_at + updated_at + } + } + } +`; + +// Query to check if a phone number is opted out +exports.CHECK_PHONE_NUMBER_OPT_OUT = ` + query CHECK_PHONE_NUMBER_OPT_OUT($bodyshopid: uuid!, $phone_number: String!) { + phone_number_opt_out(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) { + id + bodyshopid + phone_number + created_at + updated_at + } + } +`; + +// Query to check if a phone number is opted out +exports.CHECK_PHONE_NUMBER_OPT_OUT = ` + query CHECK_PHONE_NUMBER_OPT_OUT($bodyshopid: uuid!, $phone_number: String!) { + phone_number_opt_out(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) { + id + bodyshopid + phone_number + created_at + updated_at + } + } +`; + +// Mutation to delete a phone number opt-out record +exports.DELETE_PHONE_NUMBER_OPT_OUT = ` + mutation DELETE_PHONE_NUMBER_OPT_OUT($bodyshopid: uuid!, $phone_number: String!) { + delete_phone_number_opt_out(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) { + affected_rows + returning { + id + bodyshopid + phone_number + } + } + } +`; diff --git a/server/integrations/VSSTA/vsstaIntegrationRoute.js b/server/integrations/VSSTA/vsstaIntegrationRoute.js new file mode 100644 index 000000000..f7444477a --- /dev/null +++ b/server/integrations/VSSTA/vsstaIntegrationRoute.js @@ -0,0 +1,143 @@ +// Notes: At the moment we take in RO Number, and ShopID. This is not very good considering the RO number can often be null, need +// to ask if it is possible that we just send the Job ID itself, this way we don't need to really care about the bodyshop, and we +// don't risk getting a null + +const axios = require("axios"); +const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3"); +const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); +const { GET_JOB_BY_RO_NUMBER_AND_SHOP_ID, INSERT_NEW_DOCUMENT } = require("../../graphql-client/queries"); +const { InstanceRegion } = require("../../utils/instanceMgr"); +const moment = require("moment/moment"); +const client = require("../../graphql-client/graphql-client").client; + +const S3_BUCKET = process.env?.IMGPROXY_DESTINATION_BUCKET; + +/** + * @description VSSTA integration route + * @type {string[]} + */ +const requiredParams = [ + "shop_id", + "ro_nbr", + "pdf_download_link", + "company_api_key", + "scan_type", + "scan_time", + "technician", + "year", + "make", + "model" +]; + +const vsstaIntegrationRoute = async (req, res) => { + const { logger } = req; + + if (!S3_BUCKET) { + logger.log("vssta-integration-missing-bucket", "error", "api", "vssta"); + return res.status(500).json({ error: "Improper configuration" }); + } + + try { + const missingParams = requiredParams.filter((param) => !req.body[param]); + + if (missingParams.length > 0) { + logger.log(`vssta-integration-missing-param`, "error", "api", "vssta", { + params: missingParams + }); + + return res.status(400).json({ + error: "Missing required parameters", + missingParams + }); + } + + // technician, year, make, model, is also available. + const { shop_id, ro_nbr, pdf_download_link, scan_type, scan_time, company_api_key } = req.body; + + // 1. Get the job record by ro_number and shop_id + const jobResult = await client.request(GET_JOB_BY_RO_NUMBER_AND_SHOP_ID, { + roNumber: ro_nbr, + shopId: shop_id + }); + + if (!jobResult.jobs || jobResult.jobs.length === 0) { + logger.log(`vssta-integration-missing-ro`, "error", "api", "vssta"); + + return res.status(404).json({ error: "Job not found" }); + } + + const job = jobResult.jobs[0]; + + // 2. Download the base64-encoded PDF string from the provided link + const pdfResponse = await axios.get(pdf_download_link, { + responseType: "text", // Expect base64 string + headers: { + "auth-token": company_api_key + } + }); + + // 3. Decode the base64 string to a PDF buffer + const base64String = pdfResponse.data.replace(/^data:application\/pdf;base64,/, ""); + const pdfBuffer = Buffer.from(base64String, "base64"); + + // 4. Generate key for S3 + const timestamp = moment(scan_time).tz(job.bodyshop.timezone).format("YYYYMMDD-HHmmss"); + const fileName = `${timestamp}_VSSTA_${scan_type}`; + const s3Key = `${job.shopid}/${job.id}/${fileName.replace(/[^A-Z0-9]+/gi, "_")}.pdf`; + + // 5. Generate presigned URL for S3 upload + const s3Client = new S3Client({ region: InstanceRegion() }); + + const putCommand = new PutObjectCommand({ + Bucket: S3_BUCKET, + Key: s3Key, + ContentType: "application/pdf", + StorageClass: "INTELLIGENT_TIERING" + }); + + const presignedUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: 360 }); + + // 6. Upload the decoded PDF to S3 + await axios.put(presignedUrl, pdfBuffer, { + headers: { "Content-Type": "application/pdf" } + }); + + // 7. Create document record in database + const documentMeta = { + jobid: job.id, + uploaded_by: "VSSTA Integration", + name: fileName, + key: s3Key, + type: "application/pdf", + extension: "pdf", + bodyshopid: job.shopid, + size: pdfBuffer.length, + takenat: scan_time + }; + + const documentInsert = await client.request(INSERT_NEW_DOCUMENT, { + docInput: [documentMeta] + }); + + if (!documentInsert.insert_documents?.returning?.length) { + logger.log(`vssta-integration-failed-to-create-document-record`, "error", "api", "vssta", { + params: missingParams + }); + return res.status(500).json({ error: "Failed to create document record" }); + } + + return res.status(200).json({ + message: "VSSTA integration successful", + documentId: documentInsert.insert_documents.returning[0].id + }); + } catch (error) { + logger.log(`vssta-integration-general`, "error", "api", "vssta", { + error: error?.message, + stack: error?.stack + }); + + return res.status(500).json({ error: error.message }); + } +}; + +module.exports = vsstaIntegrationRoute; diff --git a/server/integrations/partsManagement/defaultNewShop.json b/server/integrations/partsManagement/defaultNewShop.json new file mode 100644 index 000000000..281f31dbb --- /dev/null +++ b/server/integrations/partsManagement/defaultNewShop.json @@ -0,0 +1,1236 @@ +{ + "associations": { + "data": [ + { + "useremail": "linda@imex.prod", + "active": false, + "authlevel": 99 + }, + { + "useremail": "patrick@imex.prod", + "active": false, + "authlevel": 99 + }, + { + "useremail": "allan@imex.prod", + "active": false, + "authlevel": 99 + }, + { + "useremail": "logan@imex.prod", + "active": false, + "authlevel": 99 + }, + { + "useremail": "nicolette@imex.prod", + "active": false, + "authlevel": 99 + } + ] + }, + "logo_img_path": { + "src": "", + "width": "", + "height": "", + "headerMargin": "135" + }, + "md_ro_statuses": { + "statuses": [ + "Open", + "Scheduled", + "Arrived", + "Hold", + "Parts", + "Body", + "Prep", + "Paint", + "Reassembly", + "Sublet", + "Detail", + "Completed", + "Delivered", + "Invoiced", + "Exported", + "Void" + ], + "default_void": "Void", + "active_statuses": [ + "Open", + "Scheduled", + "Arrived", + "Hold", + "Parts", + "Body", + "Prep", + "Paint", + "Reassembly", + "Sublet", + "Detail", + "Completed", + "Delivered" + ], + "default_arrived": "Arrived", + "default_exported": "Exported", + "default_imported": "Open", + "default_invoiced": "Invoiced", + "default_completed": "Completed", + "default_delivered": "Delivered", + "default_scheduled": "Scheduled", + "production_statuses": [ + "Arrived", + "Hold", + "Parts", + "Body", + "Prep", + "Paint", + "Reassembly", + "Sublet", + "Detail", + "Completed" + ], + "pre_production_statuses": [ + "Open", + "Scheduled" + ], + "post_production_statuses": [ + "Delivered", + "Invoiced", + "Exported" + ] + }, + "md_order_statuses": { + "default_bo": "Backordered", + "default_ordered": "Ordered", + "default_received": "Received", + "default_returned": "Returned" + }, + "shoprates": { + "rate_ats": 8.68, + "rate_ats_flat": 100.0 + }, + "md_responsibility_centers": { + "ar": { + "accountname": "ACCOUNTS RECEIVABLE" + }, + "costs": [ + { + "name": "Aftermarket", + "accountdesc": "Aftermarket", + "accountitem": "Aftermarket", + "accountname": "BODY SHOP COST:PARTS:AFTERMARKET", + "accountnumber": "Aftermarket" + }, + { + "name": "ATP", + "accountdesc": "ATP", + "accountitem": "BODY SHOP_ATP", + "accountname": "BODY SHOP COST:ATP", + "accountnumber": "ATP" + }, + { + "name": "Body", + "accountdesc": "Body", + "accountitem": "BODY SHOP_LAB", + "accountname": "BODY SHOP COST:LABOR:BODY", + "accountnumber": "BODY" + }, + { + "name": "DETAIL", + "accountdesc": "Detailing", + "accountitem": "Detail", + "accountname": "BODY SHOP COST:LABOR:DETAIL", + "accountnumber": "DETAIL" + }, + { + "name": "Diagnostic", + "accountdesc": "Diagnostic", + "accountitem": "BODY SHOP_LAB", + "accountname": "BODY SHOP COST:SUBLET", + "accountnumber": "SUBLET" + }, + { + "name": "Electrical", + "accountdesc": "Electrical", + "accountitem": "Electrical", + "accountname": "BODY SHOP COST:SUBLET", + "accountnumber": "Electrical" + }, + { + "name": "Chrome", + "accountdesc": "Chrome", + "accountitem": "Chrome", + "accountname": "BODY SHOP COST:PARTS:AFTERMARKET", + "accountnumber": "Aftermarket" + }, + { + "name": "Frame", + "accountdesc": "Frame", + "accountitem": "Frame", + "accountname": "BODY SHOP COST:LABOR:FRAME", + "accountnumber": "Frame" + }, + { + "name": "Mechanical", + "accountdesc": "Mechanical", + "accountitem": "Mechanical", + "accountname": "BODY SHOP COST:LABOR:MECHANICAL", + "accountnumber": "Mechanical" + }, + { + "name": "Refinish", + "accountdesc": "Refinish", + "accountitem": "BODY SHOP_LAR", + "accountname": "BODY SHOP COST:LABOR:REFINISH", + "accountnumber": "REFINISH" + }, + { + "name": "Structural", + "accountdesc": "Structural", + "accountitem": "Structural", + "accountname": "BODY SHOP COST:SUBLET", + "accountnumber": "SUBLET" + }, + { + "name": "Existing", + "accountdesc": "Existing", + "accountitem": "Existing", + "accountname": "BODY SHOP COST:PARTS:OTHER", + "accountnumber": "Existing" + }, + { + "name": "Glass", + "accountdesc": "Glass", + "accountitem": "Glass", + "accountname": "BODY SHOP COST:PARTS:GLASS", + "accountnumber": "Glass" + }, + { + "name": "LKQ", + "accountdesc": "LKQ", + "accountitem": "LKQ", + "accountname": "BODY SHOP COST:PARTS:LKQ", + "accountnumber": "LKQ" + }, + { + "name": "OEM", + "accountdesc": "OEM", + "accountitem": "OEM", + "accountname": "BODY SHOP COST:PARTS:OEM", + "accountnumber": "OEM" + }, + { + "name": "OEM Partial", + "accountdesc": "Partial", + "accountitem": "Partial", + "accountname": "BODY SHOP COST:PARTS:OEM", + "accountnumber": "OEM " + }, + { + "name": "Re-cored", + "accountdesc": "cored", + "accountitem": "cored", + "accountname": "BODY SHOP COST:PARTS:AFTERMARKET", + "accountnumber": "Aftermarket" + }, + { + "name": "Remanufactured", + "accountdesc": "Remanufactured", + "accountitem": "Remanufactured", + "accountname": "BODY SHOP COST:PARTS:LKQ", + "accountnumber": "Remanufactured" + }, + { + "name": "Other", + "accountdesc": "Other", + "accountitem": "Other", + "accountname": "BODY SHOP COST:PARTS:OTHER", + "accountnumber": "OTHER" + }, + { + "name": "Sublet", + "accountdesc": "Sublet to Other", + "accountitem": "Sublet", + "accountname": "BODY SHOP COST:SUBLET", + "accountnumber": "Sublet" + }, + { + "name": "Towing", + "accountdesc": "Towing", + "accountitem": "Towing", + "accountname": "BODY SHOP COST:TOWING", + "accountnumber": "Towing" + }, + { + "name": "Paint Materials", + "accountdesc": "PAINT MATERIALS", + "accountitem": "mat", + "accountname": "BODY SHOP COST:MATERIALS:PAINT", + "accountnumber": "PAINT" + }, + { + "name": "Shop Materials", + "accountdesc": "BODY MATERIALS", + "accountitem": "shop", + "accountname": "BODY SHOP COST:MATERIALS:BODY", + "accountnumber": "Shop" + }, + { + "name": "Levies", + "accountdesc": "Levies", + "accountitem": "Levies", + "accountname": "BODY SHOP COST:Levies (Tire And Battery)", + "accountnumber": "Levies" + }, + { + "name": "LA1", + "accountdesc": "LA1", + "accountname": "BODY SHOP COST:LABOR" + }, + { + "name": "LA2", + "accountdesc": "LA2", + "accountname": "BODY SHOP COST:LABOR" + }, + { + "name": "LA3", + "accountdesc": "LA3", + "accountname": "BODY SHOP COST:LABOR" + }, + { + "name": "LA4", + "accountdesc": "LA4", + "accountname": "BODY SHOP COST:LABOR" + }, + { + "name": "Sublet (L)", + "accountdesc": "Sublet Labor", + "accountname": "BODY SHOP COST:SUBLET" + }, + { + "name": "Aluminum", + "accountdesc": "Aluminum", + "accountname": "BODY SHOP COST:LABOR:BODY" + }, + { + "name": "Glass Labor", + "accountdesc": "Glass Labor", + "accountname": "BODY SHOP COST:LABOR:GLASS" + } + ], + "taxes": { + "local": { + "name": "n", + "rate": 0, + "accountdesc": "n", + "accountitem": "n" + }, + "state": { + "name": "PST on Sales", + "rate": 7, + "accountdesc": "PST on Sales", + "accountitem": "PST On Sales" + }, + "federal": { + "name": "GST on Sales", + "rate": 5, + "accountdesc": "GST on Sales", + "accountitem": "GST On Sales" + } + }, + "refund": { + "accountitem": "BODY SHOP_CUSTPAY" + }, + "profits": [ + { + "name": "Aftermarket", + "accountdesc": "Aftermarket", + "accountitem": "BODY SHOP_PAA", + "accountname": "Aftermarket", + "accountnumber": "Aftermarket" + }, + { + "name": "ATP", + "accountdesc": "ATP", + "accountitem": "BODY SHOP_ATP", + "accountname": "ATP", + "accountnumber": "ATP" + }, + { + "name": "Body", + "accountdesc": "Body Labour", + "accountitem": "BODY SHOP_LAB", + "accountname": "BODY SHOP SALES:LABOR:BODY", + "accountnumber": "Body" + }, + { + "name": "Detail", + "accountdesc": "Detail", + "accountitem": "BODY SHOP_LAU", + "accountname": "BODY SHOP SALES:LABOR:DETAIL", + "accountnumber": "Detail" + }, + { + "name": "Diagnostic", + "accountdesc": "Diagnostic", + "accountitem": "BODY SHOP_LAB", + "accountname": "BODY SHOP SALES:LABOR:BODY", + "accountnumber": "Body" + }, + { + "name": "Electrical", + "accountdesc": "Electrical", + "accountitem": "BODY SHOP_LAB", + "accountname": "BODY SHOP SALES:LABOR:BODY", + "accountnumber": "Body" + }, + { + "name": "Chrome", + "accountdesc": "Chrome", + "accountitem": "BODY SHOP_PAA", + "accountname": "Aftermarket", + "accountnumber": "Aftermarket" + }, + { + "name": "Frame", + "accountdesc": "Frame", + "accountitem": "BODY SHOP_LAF", + "accountname": "Frame", + "accountnumber": "Frame" + }, + { + "name": "Mechanical", + "accountdesc": "Mechanical", + "accountitem": "BODY SHOP_LAM", + "accountname": "Mechanical", + "accountnumber": "Mechanical" + }, + { + "name": "Refinish", + "accountdesc": "Refinish Labour", + "accountitem": "BODY SHOP_LAR", + "accountname": "BODY SHOP SALES:LABOR:REFINISH", + "accountnumber": "Refinish" + }, + { + "name": "Structural", + "accountdesc": "Structural", + "accountitem": "BODY SHOP_LAB", + "accountname": "Structural", + "accountnumber": "Structural" + }, + { + "name": "Existing", + "accountdesc": "Existing", + "accountitem": "BODY SHOP_PAO", + "accountname": "Existing", + "accountnumber": "Existing" + }, + { + "name": "Glass", + "accountdesc": "Glass", + "accountitem": "BODY SHOP_LAB", + "accountname": "Glass", + "accountnumber": "Glass" + }, + { + "name": "LKQ", + "accountdesc": "LKQ", + "accountitem": "BODY SHOP_PAL", + "accountname": "BODY SHOP SALES:PARTS:LKQ", + "accountnumber": "LKQ" + }, + { + "name": "OEM", + "accountdesc": "OEM Parts", + "accountitem": "BODY SHOP_PAN", + "accountname": "BODY SHOP SALES:PARTS:OEM", + "accountnumber": "OEM" + }, + { + "name": "OEM Partial", + "accountdesc": "OEM Partial", + "accountitem": "BODY SHOP_PAN", + "accountname": "OEM Partial", + "accountnumber": "OEM Partial" + }, + { + "name": "Re-cored", + "accountdesc": "Cored", + "accountitem": "BODY SHOP_PAO", + "accountname": "Re-cored", + "accountnumber": "Re-cored" + }, + { + "name": "Remanufactured", + "accountdesc": "Remanufactured", + "accountitem": "BODY SHOP_PAO", + "accountname": "Remanufactured", + "accountnumber": "Remanufactured" + }, + { + "name": "Other", + "accountdesc": "Other", + "accountitem": "BODY SHOP_PAO", + "accountname": "Other", + "accountnumber": "Other" + }, + { + "name": "Sublet", + "accountdesc": "Sublet", + "accountitem": "BODY SHOP_PAS", + "accountname": "BODY SHOP SALES:SUBLET", + "accountnumber": "BODY SHOP SALES:SUBLET" + }, + { + "name": "Towing", + "accountdesc": "Towing", + "accountitem": "BODY SHOP_TOW", + "accountname": "BODY SHOP SALES:TOWING", + "accountnumber": "BODY SHOP SALES:TOWING" + }, + { + "name": "Paint Materials", + "accountdesc": "Paint Material ", + "accountitem": "BODY SHOP_MAPA", + "accountname": "BODY SHOP SALES:MATERIALS:PAINT", + "accountnumber": "BODY SHOP SALES:MATERIALS:PAINT" + }, + { + "name": "Shop Materials", + "accountdesc": "Shop Material ", + "accountitem": "BODY SHOP_MASH", + "accountname": "BODY SHOP SALES:MATERIALS:SHOP", + "accountnumber": "BODY SHOP SALES:MATERIALS:SHOP" + }, + { + "name": "LA1", + "accountdesc": "LA1", + "accountitem": "BODY SHOP_LAB" + }, + { + "name": "LA2", + "accountdesc": "LA2", + "accountitem": "BODY SHOP_LAB" + }, + { + "name": "LA3", + "accountdesc": "LA3", + "accountitem": "BODY SHOP_LAB" + }, + { + "name": "LA4", + "accountdesc": "LA4", + "accountitem": "BODY SHOP_LAB" + }, + { + "name": "Sublet Labor", + "accountdesc": "PASL", + "accountitem": "BODY SHOP_PAS" + }, + { + "name": "Aluminum", + "accountdesc": "Aluminum", + "accountitem": "BODY SHOP_LAB" + }, + { + "name": "Adjustments", + "accountdesc": "Adjustments", + "accountitem": "BODY SHOP_ADJ" + }, + { + "name": "Glass Labor", + "accountdesc": "Glass Labor", + "accountitem": "BODY SHOP_LAG" + } + ], + "defaults": { + "costs": { + "ATS": "ATP", + "LA1": "LA1", + "LA2": "LA2", + "LA3": "LA3", + "LA4": "LA4", + "LAA": "Aluminum", + "LAB": "Body", + "LAD": "Diagnostic", + "LAE": "Electrical", + "LAF": "Frame", + "LAG": "Glass Labor", + "LAM": "Mechanical", + "LAR": "Refinish", + "LAS": "Structural", + "LAU": "DETAIL", + "PAA": "Aftermarket", + "PAC": "Chrome", + "PAG": "Glass", + "PAL": "LKQ", + "PAM": "Remanufactured", + "PAN": "OEM", + "PAO": "Other", + "PAP": "OEM Partial", + "PAR": "Re-cored", + "PAS": "Sublet", + "TOW": "Towing", + "MAPA": "Paint Materials", + "MASH": "Shop Materials", + "PASL": "Sublet (L)" + }, + "profits": { + "ATS": "ATP", + "LA1": "LA1", + "LA2": "LA2", + "LA3": "LA3", + "LA4": "LA4", + "LAA": "Body", + "LAB": "Body", + "LAD": "Diagnostic", + "LAE": "Electrical", + "LAF": "Frame", + "LAG": "Glass Labor", + "LAM": "Mechanical", + "LAR": "Refinish", + "LAS": "Structural", + "LAU": "Detail", + "PAA": "Aftermarket", + "PAC": "Chrome", + "PAG": "Glass", + "PAL": "LKQ", + "PAM": "Remanufactured", + "PAN": "OEM", + "PAO": "Other", + "PAP": "OEM Partial", + "PAR": "Re-cored", + "PAS": "Sublet", + "TOW": "Towing", + "MAPA": "Paint Materials", + "MASH": "Shop Materials", + "PASL": "Sublet Labor" + } + }, + "sales_tax_codes": [ + { + "code": "G", + "local": false, + "state": false, + "federal": true, + "description": "GST Only" + }, + { + "code": "S", + "state": true, + "federal": true, + "description": "Standard" + }, + { + "code": "E", + "local": false, + "state": false, + "federal": false, + "description": "Exempt" + } + ] + }, + "template_header": "", + "bill_tax_rates": { + "local_tax_rate": 0, + "state_tax_rate": 7, + "federal_tax_rate": 5 + }, + "accountingconfig": { + "tiers": 2, + "twotierpref": "source" + }, + "appt_length": 15, + "stripe_acct_id": "", + "ssbuckets": [ + { + "id": "express", + "lt": 3, + "gte": 0, + "label": "Express", + "target": 1 + }, + { + "id": "small", + "lt": 8, + "gte": 3, + "label": "Small", + "target": 4 + }, + { + "id": "medium", + "lt": 15, + "gte": 8, + "label": "Medium", + "target": 2 + }, + { + "id": "large", + "lt": 30, + "gte": 15, + "label": "Large", + "target": 1 + }, + { + "id": "heavy", + "lt": 999, + "gte": 30, + "label": "Heavy", + "target": 1 + } + ], + "scoreboard_target": { + "dailyBodyTarget": 80, + "dailyPaintTarget": 25, + "lastNumberWorkingDays": 12 + }, + "md_referral_sources": [ + "Friend", + "Word of Mouth", + "Google", + "ICBC" + ], + "md_messaging_presets": [ + { + "text": "Thanks for getting your car fixed with us.", + "label": "Thanks" + }, + { + "text": "Your appointment has been confirmed!", + "label": "Confirmation" + } + ], + "intakechecklist": { + "form": [ + { + "name": "Keys", + "type": "checkbox", + "label": "Keys", + "required": false + }, + { + "name": "Wheel Locks", + "type": "checkbox", + "label": "Wheel Locks", + "required": false + }, + { + "name": "Notes", + "type": "textarea", + "label": "Notes" + } + ], + "templates": [ + "worksheet_sorted_by_operation", + "fippa_authorization" + ] + }, + "speedprint": [ + { + "id": "New File", + "label": "New File", + "templates": [ + "coversheet_landscape", + "fippa_authorization", + "window_tag" + ] + }, + { + "id": "Final Paperwork", + "label": "Final Paperwork", + "templates": [ + "ro_totals", + "final_invoice" + ] + }, + { + "id": "Tech Paperwork", + "label": "Tech Paperwork", + "templates": [ + "worksheet_sorted_by_operation", + "supplement_request" + ] + } + ], + "md_parts_locations": [ + "Parts Room A", + "Parts Room B" + ], + "md_notes_presets": [ + { + "text": "CUSTOMER UPDATE:", + "label": "CUSTOMER UPDATE:" + } + ], + "md_rbac": { + "csi:page": 11, + "jobs:void": 80, + "shop:rbac": 99, + "bills:list": 11, + "bills:view": 11, + "csi:export": 11, + "jobs:admin": 80, + "jobs:close": 11, + "bills:enter": 11, + "jobs:create": 11, + "jobs:detail": 11, + "jobs:intake": 11, + "owners:list": 11, + "shop:config": 70, + "bills:delete": 11, + "jobs:deliver": 11, + "shop:vendors": 1, + "jobs:list-all": 11, + "owners:detail": 11, + "payments:list": 11, + "schedule:view": 11, + "bills:reexport": 11, + "contracts:list": 11, + "employees:page": 80, + "payments:enter": 11, + "phonebook:edit": 11, + "phonebook:view": 1, + "shop:dashboard": 80, + "jobs:list-ready": 11, + "jobs:partsqueue": 1, + "production:list": 1, + "scoreboard:view": 11, + "shiftclock:view": 1, + "contracts:create": 11, + "contracts:detail": 11, + "courtesycar:list": 11, + "jobs:list-active": 1, + "production:board": 1, + "timetickets:edit": 80, + "timetickets:list": 11, + "ttapprovals:view": 80, + "users:editaccess": 99, + "shop:reportcenter": 11, + "timetickets:enter": 1, + "courtesycar:create": 11, + "courtesycar:detail": 11, + "temporarydocs:view": 1, + "accounting:payables": 11, + "accounting:payments": 11, + "employee_teams:page": 80, + "jobs:available-list": 11, + "jobs:checklist-view": 11, + "ttapprovals:approve": 80, + "accounting:exportlog": 11, + "timetickets:shiftedit": 80, + "accounting:receivables": 11, + "timetickets:editcommitted": 80 + }, + "prodtargethrs": 105.0, + "md_classes": [], + "md_ins_cos": [ + { + "zip": "", + "city": "", + "name": "ICBC", + "state": "", + "street1": "" + }, + { + "zip": "", + "city": "", + "name": "ICBC-GLASS", + "state": "", + "street1": "" + }, + { + "zip": "", + "city": "", + "name": "PRIVATE", + "state": "", + "street1": "" + }, + { + "zip": "", + "city": "", + "name": "BCAA", + "state": "", + "street1": "" + }, + { + "zip": "", + "city": "", + "name": "ECONOMICAL", + "state": "", + "street1": "" + }, + { + "zip": "", + "city": "", + "name": "MPI", + "state": "", + "street1": "" + }, + { + "zip": "", + "city": "", + "name": "OPTIOM", + "state": "", + "street1": "" + }, + { + "zip": "", + "city": "", + "name": "SGI", + "state": "", + "street1": "" + }, + { + "zip": "", + "city": "", + "name": "WARRANTY", + "state": "", + "street1": "" + } + ], + "md_categories": [ + "Hit & Run", + "OEM only", + "Comp", + "Warranty" + ], + "enforce_class": false, + "md_labor_rates": [ + { + "label": "1", + "rate_la1": 0, + "rate_la2": 0, + "rate_la3": 0, + "rate_la4": 0, + "rate_laa": 0, + "rate_lab": 75.3, + "rate_lad": 0, + "rate_lae": 0, + "rate_laf": 86.07, + "rate_lag": 0, + "rate_lam": 94.39, + "rate_lar": 75.3, + "rate_las": 0, + "rate_ma2s": 0, + "rate_ma3s": 0, + "rate_mabl": 1, + "rate_macs": 1, + "rate_mahw": 0, + "rate_mapa": 45.15, + "rate_mash": 6.11, + "rate_matd": 0, + "rate_label": "PRIVATE" + } + ], + "deliverchecklist": { + "form": [ + { + "name": "Detailed", + "type": "checkbox", + "label": "Detailed?", + "required": false + }, + { + "name": "Post Scan", + "type": "checkbox", + "label": "Scanned?", + "required": false + }, + { + "name": "Comments", + "type": "text", + "label": "Additional Comments?", + "required": false + } + ], + "templates": [ + "ro_with_description", + "final_invoice", + "invoice_total_payable" + ], + "actual_delivery": true + }, + "target_touchtime": 3.0, + "appt_colors": [ + { + "color": { + "hex": "#e6b3e4", + "hsl": { + "a": 1, + "h": 301.8897637795275, + "l": 0.8, + "s": 0.4999999999999999 + }, + "hsv": { + "a": 1, + "h": 301.8897637795275, + "s": 0.22222222222222215, + "v": 0.9 + }, + "rgb": { + "a": 1, + "b": 228, + "g": 179, + "r": 230 + }, + "oldHue": 301.8897637795275, + "source": "hsl" + }, + "label": "EXPRESS 0-3H" + }, + { + "color": { + "hex": "#e5e6b3", + "hsl": { + "a": 1, + "h": 60.00000000000003, + "l": 0.8, + "s": 0.4999999999999999 + }, + "hsv": { + "a": 1, + "h": 60.00000000000003, + "s": 0.22222222222222215, + "v": 0.9 + }, + "rgb": { + "a": 1, + "b": 179, + "g": 230, + "r": 229 + }, + "oldHue": 60, + "source": "hsl" + }, + "label": "SMALL 3-8H" + }, + { + "color": { + "hex": "#40bf5e", + "hsl": { + "a": 1, + "h": 134.11764705882354, + "l": 0.5, + "s": 0.5 + }, + "hsv": { + "a": 1, + "h": 134.11764705882354, + "s": 0.6666666666666666, + "v": 0.75 + }, + "rgb": { + "a": 1, + "b": 94, + "g": 191, + "r": 64 + }, + "oldHue": 134.11764705882354, + "source": "hsl" + }, + "label": "MEDIUM 8-15H" + }, + { + "color": { + "hex": "#4085bf", + "hsl": { + "a": 1, + "h": 207.4015748031496, + "l": 0.5, + "s": 0.5 + }, + "hsv": { + "a": 1, + "h": 207.4015748031496, + "s": 0.6666666666666666, + "v": 0.75 + }, + "rgb": { + "a": 1, + "b": 191, + "g": 133, + "r": 64 + }, + "oldHue": 207.4015748031496, + "source": "hsl" + }, + "label": "LARGE 15-30H" + }, + { + "color": { + "hex": "#bf4068", + "hsl": { + "a": 1, + "h": 341.12359550561797, + "l": 0.5, + "s": 0.5 + }, + "hsv": { + "a": 1, + "h": 341.12359550561797, + "s": 0.6666666666666666, + "v": 0.75 + }, + "rgb": { + "a": 1, + "b": 104, + "g": 64, + "r": 191 + }, + "oldHue": 341.12359550561797, + "source": "hsl" + }, + "label": "HEAVY 30-999H" + }, + { + "color": { + "hex": "#b3e6e2", + "hsl": { + "a": 1, + "h": 175.95505617977528, + "l": 0.8, + "s": 0.4999999999999999 + }, + "hsv": { + "a": 1, + "h": 175.95505617977528, + "s": 0.22222222222222215, + "v": 0.9 + }, + "rgb": { + "a": 1, + "b": 226, + "g": 230, + "r": 179 + }, + "oldHue": 175.95505617977528, + "source": "hsl" + }, + "label": "EST" + }, + { + "color": { + "hex": "#3b2d86", + "hsl": { + "a": 1, + "h": 249.99999999999994, + "l": 0.35, + "s": 0.49999999999999983 + }, + "hsv": { + "a": 1, + "h": 249.99999999999994, + "s": 0.6666666666666665, + "v": 0.5249999999999999 + }, + "rgb": { + "a": 1, + "b": 134, + "g": 45, + "r": 59 + }, + "oldHue": 249.99999999999994, + "source": "hsl" + }, + "label": "OTHER APPT" + } + ], + "appt_alt_transport": [ + "No car", + "Rental", + "CC", + "Vehicle Pick up", + "Ride", + "Internal" + ], + "schedule_start_time": "2020-12-15T16:00:00+00:00", + "schedule_end_time": "2021-02-12T01:30:00+00:00", + "default_adjustment_rate": 0, + "workingdays": { + "friday": true, + "monday": true, + "sunday": false, + "tuesday": true, + "saturday": false, + "thursday": true, + "wednesday": true + }, + "use_fippa": false, + "md_payment_types": [ + "Cash", + "Cheque", + "Master Card", + "Visa", + "Debit", + "EFT", + "American Express" + ], + "md_hour_split": { + "prep": 0.0, + "paint": 0.0 + }, + "sub_status": "active", + "jobsizelimit": 104857600, + "md_ccc_rates": [], + "enforce_referral": false, + "last_name_first": true, + "jc_hourly_rates": { + "mapa": 33.0, + "mash": 4.2 + }, + "md_jobline_presets": [ + { + "label": "Glass Labour", + "line_desc": "Glass Labour", + "mod_lb_hrs": null, + "mod_lbr_ty": "LAG" + }, + { + "label": "Urethane", + "part_qty": 1, + "act_price": 48, + "line_desc": "Urethane", + "part_type": "PAL" + }, + { + "label": "Windshield OEM", + "part_qty": 1, + "part_type": "PAN" + }, + { + "label": "PDR", + "part_type": "PAS", + "oem_partno": "" + }, + { + "label": "Windshield Aftermarket", + "part_qty": 1, + "part_type": "PAA" + }, + { + "label": "Moulding", + "part_qty": 1, + "part_type": "PAA" + } + ], + "cdk_dealerid": null, + "features": { + "allAccess": true, + "singleDeviceOnly": false + }, + "attach_pdf_to_email": true, + "tt_allow_post_to_invoiced": true, + "cdk_configuration": null, + "md_estimators": [], + "md_ded_notes": [ + "Paid", + "Owning" + ], + "pbs_configuration": {}, + "pbs_serialnumber": null, + "md_filehandlers": [], + "md_email_cc": { + "parts_order": [] + }, + "timezone": "America/Vancouver" +} diff --git a/server/integrations/partsManagement/partsManagementProvisioning.js b/server/integrations/partsManagement/partsManagementProvisioning.js new file mode 100644 index 000000000..8e2213bdf --- /dev/null +++ b/server/integrations/partsManagement/partsManagementProvisioning.js @@ -0,0 +1,257 @@ +const crypto = require("crypto"); +const admin = require("firebase-admin"); +const client = require("../../graphql-client/graphql-client").client; +const DefaultNewShop = require("./defaultNewShop.json"); + +/** + * Ensures that the required fields are present in the payload. + * @param payload + * @param fields + */ +const requireFields = (payload, fields) => { + for (const field of fields) { + if (!payload[field]) { + throw { status: 400, message: `${field} is required.` }; + } + } +}; + +/** + * Ensures that the email is not already registered in Firebase. + * @param email + * @returns {Promise} + */ +const ensureEmailNotRegistered = async (email) => { + try { + await admin.auth().getUserByEmail(email); + throw { status: 400, message: "userEmail is already registered in Firebase." }; + } catch (err) { + if (err.code !== "auth/user-not-found") { + throw { status: 500, message: "Error validating userEmail uniqueness", detail: err }; + } + } +}; + +/** + * Creates a new Firebase user with the provided email. + * @param email + * @returns {Promise} + */ +const createFirebaseUser = async (email) => { + return admin.auth().createUser({ email }); +}; + +/** + * Deletes a Firebase user by their UID. + * @param uid + * @returns {Promise} + */ +const deleteFirebaseUser = async (uid) => { + return admin.auth().deleteUser(uid); +}; + +/** + * Generates a password reset link for the given email. + * @param email + * @returns {Promise} + */ +const generateResetLink = async (email) => { + return admin.auth().generatePasswordResetLink(email); +}; + +/** + * Ensures that the external shop ID is unique in the database. + * @param externalId + * @returns {Promise} + */ +const ensureExternalIdUnique = async (externalId) => { + const query = ` + query CHECK_KEY($key: String!) { + bodyshops(where: { external_shop_id: { _eq: $key } }) { + external_shop_id + } + }`; + const resp = await client.request(query, { key: externalId }); + if (resp.bodyshops.length) { + throw { status: 400, message: `external_shop_id '${externalId}' is already in use.` }; + } +}; + +/** + * Inserts a new bodyshop into the database. + * @param input + * @returns {Promise<*>} + */ +const insertBodyshop = async (input) => { + const mutation = ` + mutation CREATE_SHOP($bs: bodyshops_insert_input!) { + insert_bodyshops_one(object: $bs) { id } + }`; + const resp = await client.request(mutation, { bs: input }); + return resp.insert_bodyshops_one.id; +}; + +/** + * Deletes all vendors associated with a specific shop ID. + * @param shopId + * @returns {Promise} + */ +const deleteVendorsByShop = async (shopId) => { + const mutation = ` + mutation DELETE_VENDORS($shopId: uuid!) { + delete_vendors(where: { shopid: { _eq: $shopId } }) { + affected_rows + } + }`; + await client.request(mutation, { shopId }); +}; + +/** + * Deletes a bodyshop by its ID. + * @param shopId + * @returns {Promise} + */ +const deleteBodyshop = async (shopId) => { + const mutation = ` + mutation DELETE_SHOP($id: uuid!) { + delete_bodyshops_by_pk(id: $id) { id } + }`; + await client.request(mutation, { id: shopId }); +}; + +/** + * Inserts a new user association into the database. + * @param uid + * @param email + * @param shopId + * @returns {Promise<*>} + */ +const insertUserAssociation = async (uid, email, shopId) => { + const mutation = ` + mutation CREATE_USER($u: users_insert_input!) { + insert_users_one(object: $u) { + id: authid + email + } + }`; + const vars = { + u: { + email, + authid: uid, + validemail: true, + associations: { + data: [{ shopid: shopId, authlevel: 80, active: true }] + } + } + }; + const resp = await client.request(mutation, vars); + return resp.insert_users_one; +}; + +/** + * Handles the provisioning of a new parts management shop and user. + * @param req + * @param res + * @returns {Promise<*>} + */ +const partsManagementProvisioning = async (req, res) => { + const { logger } = req; + const p = { ...req.body, userEmail: req.body.userEmail?.toLowerCase() }; + + try { + // Validate inputs + await ensureEmailNotRegistered(p.userEmail); + requireFields(p, [ + "external_shop_id", + "shopname", + "address1", + "city", + "state", + "zip_post", + "country", + "email", + "phone", + "userEmail" + ]); + await ensureExternalIdUnique(p.external_shop_id); + + logger.log("admin-create-shop-user", "debug", p.userEmail, null, { + request: req.body, + ioadmin: true + }); + + // Create shop + const shopInput = { + shopname: p.shopname, + address1: p.address1, + address2: p.address2 || null, + city: p.city, + state: p.state, + zip_post: p.zip_post, + country: p.country, + email: p.email, + external_shop_id: p.external_shop_id, + timezone: p.timezone, + phone: p.phone, + logo_img_path: { + src: p.logoUrl, + width: "", + height: "", + headerMargin: DefaultNewShop.logo_img_path.headerMargin + }, + md_ro_statuses: DefaultNewShop.md_ro_statuses, + vendors: { + data: p.vendors.map((v) => ({ + name: v.name, + street1: v.street1 || null, + street2: v.street2 || null, + city: v.city || null, + state: v.state || null, + zip: v.zip || null, + country: v.country || null, + email: v.email || null, + discount: v.discount ?? 0, + due_date: v.due_date ?? null, + cost_center: v.cost_center || null, + favorite: v.favorite ?? [], + phone: v.phone || null, + active: v.active ?? true, + dmsid: v.dmsid || null + })) + } + }; + const newShopId = await insertBodyshop(shopInput); + + // Create user + association + const userRecord = await createFirebaseUser(p.userEmail); + const resetLink = await generateResetLink(p.userEmail); + const createdUser = await insertUserAssociation(userRecord.uid, p.userEmail, newShopId); + + return res.status(200).json({ + shop: { id: newShopId, shopname: p.shopname }, + user: { + id: createdUser.id, + email: createdUser.email, + resetLink + } + }); + } catch (err) { + logger.log("admin-create-shop-user-error", "error", p.userEmail, null, { + message: err.message, + detail: err.detail || err + }); + + // Cleanup on failure + if (err.userRecord) { + await deleteFirebaseUser(err.userRecord.uid).catch(() => {}); + } + if (err.newShopId) { + await deleteVendorsByShop(err.newShopId).catch(() => {}); + await deleteBodyshop(err.newShopId).catch(() => {}); + } + + return res.status(err.status || 500).json({ error: err.message || "Internal server error" }); + } +}; + +module.exports = partsManagementProvisioning; diff --git a/server/integrations/partsManagement/swagger.yaml b/server/integrations/partsManagement/swagger.yaml new file mode 100644 index 000000000..40e28e9b5 --- /dev/null +++ b/server/integrations/partsManagement/swagger.yaml @@ -0,0 +1,160 @@ +openapi: 3.0.3 +info: + title: Parts Management Provisioning API + description: API endpoint to provision a new shop and user in the Parts Management system. + version: 1.0.0 + +paths: + /parts-management/provision: + post: + summary: Provision a new parts management shop and user + operationId: partsManagementProvisioning + tags: + - Parts Management + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - external_shop_id + - shopname + - address1 + - city + - state + - zip_post + - country + - email + - phone + - userEmail + properties: + external_shop_id: + type: string + description: External shop ID (must be unique) + shopname: + type: string + address1: + type: string + address2: + type: string + nullable: true + city: + type: string + state: + type: string + zip_post: + type: string + country: + type: string + email: + type: string + phone: + type: string + userEmail: + type: string + format: email + logoUrl: + type: string + format: uri + nullable: true + timezone: + type: string + nullable: true + vendors: + type: array + items: + type: object + properties: + name: + type: string + street1: + type: string + nullable: true + street2: + type: string + nullable: true + city: + type: string + nullable: true + state: + type: string + nullable: true + zip: + type: string + nullable: true + country: + type: string + nullable: true + email: + type: string + format: email + nullable: true + discount: + type: number + nullable: true + due_date: + type: string + format: date + nullable: true + cost_center: + type: string + nullable: true + favorite: + type: array + items: + type: string + nullable: true + phone: + type: string + nullable: true + active: + type: boolean + nullable: true + dmsid: + type: string + nullable: true + responses: + '200': + description: Shop and user successfully created + content: + application/json: + schema: + type: object + properties: + shop: + type: object + properties: + id: + type: string + format: uuid + shopname: + type: string + user: + type: object + properties: + id: + type: string + email: + type: string + resetLink: + type: string + format: uri + '400': + description: Bad request (missing or invalid fields) + content: + application/json: + schema: + type: object + properties: + error: + type: string + '500': + description: Internal server error + content: + application/json: + schema: + type: object + properties: + error: + type: string diff --git a/server/intellipay/intellipay.js b/server/intellipay/intellipay.js index 29f8e978a..faa725faf 100644 --- a/server/intellipay/intellipay.js +++ b/server/intellipay/intellipay.js @@ -144,6 +144,7 @@ const paymentRefund = async (req, res) => { logger.log("intellipay-refund-success", "DEBUG", req.user?.email, null, { requestOptions: options, + response: response?.data, ...logResponseMeta }); diff --git a/server/intellipay/lib/handleInvoiceBasedPayment.js b/server/intellipay/lib/handleInvoiceBasedPayment.js index 21e4500a6..d5fc97b9c 100644 --- a/server/intellipay/lib/handleInvoiceBasedPayment.js +++ b/server/intellipay/lib/handleInvoiceBasedPayment.js @@ -107,18 +107,25 @@ const handleInvoiceBasedPayment = async (values, logger, logMeta, res) => { }); // Create payment response record - const responseResults = await gqlClient.request(INSERT_PAYMENT_RESPONSE, { - paymentResponse: { - amount: values.total, - bodyshopid: bodyshop.id, - paymentid: paymentResult.id, - jobid: job.id, - declinereason: "Approved", - ext_paymentid: values.paymentid, - successful: true, - response: values - } - }); + const responseResults = await gqlClient + .request(INSERT_PAYMENT_RESPONSE, { + paymentResponse: { + amount: values.total, + bodyshopid: bodyshop.id, + paymentid: paymentResult.insert_payments.returning[0].id, + jobid: job.id, + declinereason: "Approved", + ext_paymentid: values.paymentid, + successful: true, + response: values + } + }) + .catch((err) => { + logger.log("intellipay-postback-invoice-response-error", "ERROR", "api", null, { + err, + ...logMeta + }); + }); logger.log("intellipay-postback-invoice-response-success", "DEBUG", "api", null, { responseResults, diff --git a/server/intellipay/lib/sendPaymentNotificationEmail.js b/server/intellipay/lib/sendPaymentNotificationEmail.js index 2f83d3a3b..c7b55d6d4 100644 --- a/server/intellipay/lib/sendPaymentNotificationEmail.js +++ b/server/intellipay/lib/sendPaymentNotificationEmail.js @@ -1,5 +1,6 @@ const { sendTaskEmail } = require("../../email/sendemail"); const generateEmailTemplate = require("../../email/generateTemplate"); +const { InstanceEndpoints } = require("../../utils/instanceMgr"); /** * @description Send notification email to the user @@ -22,11 +23,9 @@ const sendPaymentNotificationEmail = async (userEmail, jobs, partialPayments, lo body: jobs.jobs .map( (job) => - `Reference: ${job.ro_number || "N/A"} | ${ - job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim() - } | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}` + `

    Reference: ${job.ro_number || "N/A"} | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}

    ` ) - .join("
    ") + .join("") }) }); } catch (error) { diff --git a/server/intellipay/lib/tests/handleInvoiceBasedPayment.test.js b/server/intellipay/lib/tests/handleInvoiceBasedPayment.test.js index 01352191e..ce15861eb 100644 --- a/server/intellipay/lib/tests/handleInvoiceBasedPayment.test.js +++ b/server/intellipay/lib/tests/handleInvoiceBasedPayment.test.js @@ -37,7 +37,9 @@ beforeEach(() => { ] }) .mockResolvedValueOnce({ - id: "payment123" + insert_payments: { + returning: [{ id: "payment123" }] + } }) .mockResolvedValueOnce({ insert_payment_response: { diff --git a/server/job/job-costing.js b/server/job/job-costing.js index 1210b92e9..d1fe8b3d7 100644 --- a/server/job/job-costing.js +++ b/server/job/job-costing.js @@ -19,7 +19,7 @@ async function JobCosting(req, res) { const client = req.userGraphQLClient; //Uncomment for further testing - // logger.log("job-costing-start", "DEBUG", req.user.email, jobid, null); + logger.log("job-costing-start", "DEBUG", req.user.email, jobid, null); try { const resp = await client.setHeaders({ Authorization: BearerToken }).request(queries.QUERY_JOB_COSTING_DETAILS, { @@ -47,9 +47,9 @@ async function JobCostingMulti(req, res) { const client = req.userGraphQLClient; //Uncomment for further testing - // logger.log("job-costing-multi-start", "DEBUG", req?.user?.email, null, { - // jobids - // }); + logger.log("job-costing-multi-start", "DEBUG", req?.user?.email, null, { + jobids + }); try { const resp = await client @@ -567,6 +567,29 @@ function GenerateCostingData(job) { ); } + if (InstanceManager({ imex: false, rome: true })) { + const stlTowing = job.cieca_stl?.data.find((c) => c.ttl_type === "OTTW"); + const stlStorage = job.cieca_stl?.data.find((c) => c.ttl_type === "OTST"); + + if (!jobLineTotalsByProfitCenter.additional[defaultProfits["TOW"]]) + jobLineTotalsByProfitCenter.additional[defaultProfits["TOW"]] = Dinero(); + + jobLineTotalsByProfitCenter.additional[defaultProfits["TOW"]] = stlTowing + ? Dinero({ amount: Math.round(stlTowing.ttl_amt * 100) }) + : Dinero({ + amount: Math.round((job.towing_payable || 0) * 100) + }); + + if (!jobLineTotalsByProfitCenter.additional[defaultProfits["STO"]]) + jobLineTotalsByProfitCenter.additional[defaultProfits["STO"]] = Dinero(); + + jobLineTotalsByProfitCenter.additional[defaultProfits["STO"]] = stlStorage + ? Dinero({ amount: Math.round(stlStorage.ttl_amt * 100) }) + : Dinero({ + amount: Math.round((job.storage_payable || 0) * 100) + }); + } + //Is it a DMS Setup? const selectedDmsAllocationConfig = (job.bodyshop.md_responsibility_centers.dms_defaults && diff --git a/server/job/job-lifecycle.js b/server/job/job-lifecycle.js index 7076069f6..9e56d4708 100644 --- a/server/job/job-lifecycle.js +++ b/server/job/job-lifecycle.js @@ -8,6 +8,7 @@ const getLifecycleStatusColor = require("../utils/getLifecycleStatusColor"); const jobLifecycle = async (req, res) => { // Grab the jobids and statuses from the request body const { jobids, statuses } = req.body; + const { logger } = req; if (!jobids) { return res.status(400).json({ @@ -16,102 +17,118 @@ const jobLifecycle = async (req, res) => { } const jobIDs = _.isArray(jobids) ? jobids : [jobids]; - const client = req.userGraphQLClient; - const resp = await client.request(queries.QUERY_TRANSITIONS_BY_JOBID, { jobids: jobIDs }); - const transitions = resp.transitions; + logger.log("job-lifecycle-start", "DEBUG", req?.user?.email, null, { + jobids: jobIDs + }); + + try { + const client = req.userGraphQLClient; + const resp = await client.request(queries.QUERY_TRANSITIONS_BY_JOBID, { jobids: jobIDs }); + + const transitions = resp.transitions; + + if (!transitions) { + return res.status(200).json({ + jobIDs, + transitions: [] + }); + } + + const transitionsByJobId = _.groupBy(resp.transitions, "jobid"); + + const groupedTransitions = {}; + const allDurations = []; + + for (let jobId in transitionsByJobId) { + let lifecycle = transitionsByJobId[jobId].map((transition) => { + transition.start_readable = transition.start ? moment(transition.start).fromNow() : "N/A"; + transition.end_readable = transition.end ? moment(transition.end).fromNow() : "N/A"; + + if (transition.duration) { + transition.duration_seconds = Math.round(transition.duration / 1000); + transition.duration_minutes = Math.round(transition.duration_seconds / 60); + let duration = moment.duration(transition.duration); + transition.duration_readable = durationToHumanReadable(duration); + } else { + transition.duration_seconds = 0; + transition.duration_minutes = 0; + transition.duration_readable = "N/A"; + } + return transition; + }); + + const durations = calculateStatusDuration(lifecycle, statuses); + + groupedTransitions[jobId] = { + lifecycle, + durations + }; + + if (durations?.summations) { + allDurations.push(durations.summations); + } + } + + const finalSummations = []; + const flatGroupedAllDurations = _.groupBy(allDurations.flat(), "status"); + + const finalStatusCounts = Object.keys(flatGroupedAllDurations).reduce((acc, status) => { + acc[status] = flatGroupedAllDurations[status].length; + return acc; + }, {}); + // Calculate total value of all statuses + const finalTotal = Object.values(flatGroupedAllDurations).reduce((total, statusArr) => { + return total + statusArr.reduce((acc, curr) => acc + curr.value, 0); + }, 0); + + Object.keys(flatGroupedAllDurations).forEach((status) => { + const value = flatGroupedAllDurations[status].reduce((acc, curr) => acc + curr.value, 0); + const humanReadable = durationToHumanReadable(moment.duration(value)); + const percentage = finalTotal > 0 ? (value / finalTotal) * 100 : 0; + const color = getLifecycleStatusColor(status); + const roundedPercentage = `${Math.round(percentage)}%`; + const averageValue = _.size(jobIDs) > 0 ? value / jobIDs.length : 0; + const averageHumanReadable = durationToHumanReadable(moment.duration(averageValue)); + finalSummations.push({ + status, + value, + humanReadable, + percentage, + color, + roundedPercentage, + averageValue, + averageHumanReadable + }); + }); - if (!transitions) { return res.status(200).json({ jobIDs, - transitions: [] - }); - } - - const transitionsByJobId = _.groupBy(resp.transitions, "jobid"); - - const groupedTransitions = {}; - const allDurations = []; - - for (let jobId in transitionsByJobId) { - let lifecycle = transitionsByJobId[jobId].map((transition) => { - transition.start_readable = transition.start ? moment(transition.start).fromNow() : "N/A"; - transition.end_readable = transition.end ? moment(transition.end).fromNow() : "N/A"; - - if (transition.duration) { - transition.duration_seconds = Math.round(transition.duration / 1000); - transition.duration_minutes = Math.round(transition.duration_seconds / 60); - let duration = moment.duration(transition.duration); - transition.duration_readable = durationToHumanReadable(duration); - } else { - transition.duration_seconds = 0; - transition.duration_minutes = 0; - transition.duration_readable = "N/A"; + transition: groupedTransitions, + durations: { + jobs: jobIDs.length, + summations: finalSummations, + totalStatuses: finalSummations.length, + total: finalTotal, + statusCounts: finalStatusCounts, + humanReadable: durationToHumanReadable(moment.duration(finalTotal)), + averageValue: _.size(jobIDs) > 0 ? finalTotal / jobIDs.length : 0, + averageHumanReadable: + _.size(jobIDs) > 0 + ? durationToHumanReadable(moment.duration(finalTotal / jobIDs.length)) + : durationToHumanReadable(moment.duration(0)) } - return transition; }); - - const durations = calculateStatusDuration(lifecycle, statuses); - - groupedTransitions[jobId] = { - lifecycle, - durations - }; - - if (durations?.summations) { - allDurations.push(durations.summations); - } + } catch (error) { + logger.log("job-lifecycle-error", "ERROR", req?.user?.email, null, { + jobids: jobIDs, + statuses: statuses ? JSON.stringify(statuses) : "N/A", + error: error.message + }); + return res.status(500).json({ + error: "Internal server error" + }); } - - const finalSummations = []; - const flatGroupedAllDurations = _.groupBy(allDurations.flat(), "status"); - - const finalStatusCounts = Object.keys(flatGroupedAllDurations).reduce((acc, status) => { - acc[status] = flatGroupedAllDurations[status].length; - return acc; - }, {}); - // Calculate total value of all statuses - const finalTotal = Object.values(flatGroupedAllDurations).reduce((total, statusArr) => { - return total + statusArr.reduce((acc, curr) => acc + curr.value, 0); - }, 0); - - Object.keys(flatGroupedAllDurations).forEach((status) => { - const value = flatGroupedAllDurations[status].reduce((acc, curr) => acc + curr.value, 0); - const humanReadable = durationToHumanReadable(moment.duration(value)); - const percentage = finalTotal > 0 ? (value / finalTotal) * 100 : 0; - const color = getLifecycleStatusColor(status); - const roundedPercentage = `${Math.round(percentage)}%`; - const averageValue = _.size(jobIDs) > 0 ? value / jobIDs.length : 0; - const averageHumanReadable = durationToHumanReadable(moment.duration(averageValue)); - finalSummations.push({ - status, - value, - humanReadable, - percentage, - color, - roundedPercentage, - averageValue, - averageHumanReadable - }); - }); - - return res.status(200).json({ - jobIDs, - transition: groupedTransitions, - durations: { - jobs: jobIDs.length, - summations: finalSummations, - totalStatuses: finalSummations.length, - total: finalTotal, - statusCounts: finalStatusCounts, - humanReadable: durationToHumanReadable(moment.duration(finalTotal)), - averageValue: _.size(jobIDs) > 0 ? finalTotal / jobIDs.length : 0, - averageHumanReadable: - _.size(jobIDs) > 0 - ? durationToHumanReadable(moment.duration(finalTotal / jobIDs.length)) - : durationToHumanReadable(moment.duration(0)) - } - }); }; module.exports = jobLifecycle; diff --git a/server/job/job-totals-USA.js b/server/job/job-totals-USA.js index 5dc0d6740..2f694b65c 100644 --- a/server/job/job-totals-USA.js +++ b/server/job/job-totals-USA.js @@ -381,7 +381,7 @@ async function CalculateRatesTotals({ job, client }) { if (item.mod_lbr_ty) { //Check to see if it has 0 hours and a price instead. - if (item.mod_lb_hrs === 0 && item.act_price > 0 && item.lbr_op === "OP14") { + if (item.lbr_op === "OP14" && item.act_price > 0 && (!item.part_type || item.mod_lb_hrs === 0)) { //Scenario where SGI may pay out hours using a part price. if (!ret[item.mod_lbr_ty.toLowerCase()].total) { ret[item.mod_lbr_ty.toLowerCase()].total = Dinero(); diff --git a/server/job/job-totals.js b/server/job/job-totals.js index d370b57df..5f28ac067 100644 --- a/server/job/job-totals.js +++ b/server/job/job-totals.js @@ -314,7 +314,8 @@ function CalculateRatesTotals(ratesList) { if (item.mod_lbr_ty) { //Check to see if it has 0 hours and a price instead. - if (item.mod_lb_hrs === 0 && item.act_price > 0 && item.lbr_op === "OP14") { + //Extend for when there are hours and a price. + if (item.lbr_op === "OP14" && item.act_price > 0 && (!item.part_type || item.mod_lb_hrs === 0)) { //Scenario where SGI may pay out hours using a part price. if (!ret[item.mod_lbr_ty.toLowerCase()].total) { ret[item.mod_lbr_ty.toLowerCase()].total = Dinero(); diff --git a/server/media/imgproxy-media.js b/server/media/imgproxy-media.js index fdb313984..576c1cd69 100644 --- a/server/media/imgproxy-media.js +++ b/server/media/imgproxy-media.js @@ -1,8 +1,12 @@ const path = require("path"); -require("dotenv").config({ - path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) -}); const logger = require("../utils/logger"); +const { Upload } = require("@aws-sdk/lib-storage"); +const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); +const { InstanceRegion } = require("../utils/instanceMgr"); +const archiver = require("archiver"); +const stream = require("node:stream"); +const base64UrlEncode = require("./util/base64UrlEncode"); +const createHmacSha256 = require("./util/createHmacSha256"); const { S3Client, PutObjectCommand, @@ -10,35 +14,38 @@ const { CopyObjectCommand, DeleteObjectCommand } = require("@aws-sdk/client-s3"); -const { Upload } = require("@aws-sdk/lib-storage"); - -const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); -const crypto = require("crypto"); -const { InstanceRegion } = require("../utils/instanceMgr"); const { GET_DOCUMENTS_BY_JOB, QUERY_TEMPORARY_DOCS, GET_DOCUMENTS_BY_IDS, + GET_DOCUMENTS_BY_BILL, DELETE_MEDIA_DOCUMENTS } = require("../graphql-client/queries"); -const archiver = require("archiver"); -const stream = require("node:stream"); +const yazl = require("yazl"); const imgproxyBaseUrl = process.env.IMGPROXY_BASE_URL; // `https://u4gzpp5wm437dnm75qa42tvza40fguqr.lambda-url.ca-central-1.on.aws` //Direct Lambda function access to bypass CDN. -const imgproxyKey = process.env.IMGPROXY_KEY; const imgproxySalt = process.env.IMGPROXY_SALT; const imgproxyDestinationBucket = process.env.IMGPROXY_DESTINATION_BUCKET; -//Generate a signed upload link for the S3 bucket. -//All uploads must be going to the same shop and jobid. -exports.generateSignedUploadUrls = async (req, res) => { +/** + * Generate a Signed URL Link for the s3 bucket. + * All Uploads must be going to the same Shop and JobId + * @param req + * @param res + * @returns {Promise<*>} + */ +const generateSignedUploadUrls = async (req, res) => { const { filenames, bodyshopid, jobid } = req.body; try { - logger.log("imgproxy-upload-start", "DEBUG", req.user?.email, jobid, { filenames, bodyshopid, jobid }); + logger.log("imgproxy-upload-start", "DEBUG", req.user?.email, jobid, { + filenames, + bodyshopid, + jobid + }); const signedUrls = []; for (const filename of filenames) { - const key = filename; + const key = filename; const client = new S3Client({ region: InstanceRegion() }); const command = new PutObjectCommand({ Bucket: imgproxyDestinationBucket, @@ -50,24 +57,32 @@ exports.generateSignedUploadUrls = async (req, res) => { } logger.log("imgproxy-upload-success", "DEBUG", req.user?.email, jobid, { signedUrls }); - res.json({ + + return res.json({ success: true, signedUrls }); } catch (error) { - res.status(400).json({ - success: false, + logger.log("imgproxy-upload-error", "ERROR", req.user?.email, jobid, { message: error.message, stack: error.stack }); - logger.log("imgproxy-upload-error", "ERROR", req.user?.email, jobid, { + + return res.status(400).json({ + success: false, message: error.message, stack: error.stack }); } }; -exports.getThumbnailUrls = async (req, res) => { +/** + * Get Thumbnail URLS + * @param req + * @param res + * @returns {Promise<*>} + */ +const getThumbnailUrls = async (req, res) => { const { jobid, billid } = req.body; try { @@ -76,9 +91,11 @@ exports.getThumbnailUrls = async (req, res) => { //Delayed as the key structure may change slightly from what it is currently and will require evaluating mobile components. const client = req.userGraphQLClient; //If there's no jobid and no billid, we're in temporary documents. - const data = await (jobid - ? client.request(GET_DOCUMENTS_BY_JOB, { jobId: jobid }) - : client.request(QUERY_TEMPORARY_DOCS)); + const data = await ( + billid ? client.request(GET_DOCUMENTS_BY_BILL, { billId: billid }) : + jobid + ? client.request(GET_DOCUMENTS_BY_JOB, { jobId: jobid }) + : client.request(QUERY_TEMPORARY_DOCS)); const thumbResizeParams = `rs:fill:250:250:1/g:ce`; const s3client = new S3Client({ region: InstanceRegion() }); @@ -86,24 +103,19 @@ exports.getThumbnailUrls = async (req, res) => { for (const document of data.documents) { //Format to follow: - /////< base 64 URL encoded to image path> - + /////< base 64 URL encoded to image path> //When working with documents from Cloudinary, the URL does not include the extension. - let key; - if (/\.[^/.]+$/.test(document.key)) { - key = document.key; - } else { - key = `${document.key}.${document.extension.toLowerCase()}`; - } + + let key = keyStandardize(document) // Build the S3 path to the object. const fullS3Path = `s3://${imgproxyDestinationBucket}/${key}`; const base64UrlEncodedKeyString = base64UrlEncode(fullS3Path); + //Thumbnail Generation Block const thumbProxyPath = `${thumbResizeParams}/${base64UrlEncodedKeyString}`; const thumbHmacSalt = createHmacSha256(`${imgproxySalt}/${thumbProxyPath}`); //Full Size URL block - const fullSizeProxyPath = `${base64UrlEncodedKeyString}`; const fullSizeHmacSalt = createHmacSha256(`${imgproxySalt}/${fullSizeProxyPath}`); @@ -114,8 +126,8 @@ exports.getThumbnailUrls = async (req, res) => { Bucket: imgproxyDestinationBucket, Key: key }); - const presignedGetUrl = await getSignedUrl(s3client, command, { expiresIn: 360 }); - s3Props.presignedGetUrl = presignedGetUrl; + + s3Props.presignedGetUrl = await getSignedUrl(s3client, command, { expiresIn: 360 }); const originalProxyPath = `raw:1/${base64UrlEncodedKeyString}`; const originalHmacSalt = createHmacSha256(`${imgproxySalt}/${originalProxyPath}`); @@ -133,7 +145,7 @@ exports.getThumbnailUrls = async (req, res) => { }); } - res.json(proxiedUrls); + return res.json(proxiedUrls); //Iterate over them, build the link based on the media type, and return the array. } catch (error) { logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobid, { @@ -142,78 +154,95 @@ exports.getThumbnailUrls = async (req, res) => { message: error.message, stack: error.stack }); - res.status(400).json({ message: error.message, stack: error.stack }); + + return res.status(400).json({ message: error.message, stack: error.stack }); } }; -exports.getBillFiles = async (req, res) => { - //Givena bill ID, get the documents associated to it. -}; +/** + * Download Files + * @param req + * @param res + * @returns {Promise<*>} + */ +const downloadFiles = async (req, res) => { + const { jobId, billid, documentids } = req.body; -exports.downloadFiles = async (req, res) => { - //Given a series of document IDs or keys, generate a file (or a link) to download all images in bulk - const { jobid, billid, documentids } = req.body; + logger.log("imgproxy-download", "DEBUG", req.user?.email, jobId, { billid, jobId, documentids }); + + const client = req.userGraphQLClient; + let data; try { - logger.log("imgproxy-download", "DEBUG", req.user?.email, jobid, { billid, jobid, documentids }); - - //Delayed as the key structure may change slightly from what it is currently and will require evaluating mobile components. - const client = req.userGraphQLClient; - //Query for the keys of the document IDs - const data = await client.request(GET_DOCUMENTS_BY_IDS, { documentIds: documentids }); - //Using the Keys, get all of the S3 links, zip them, and send back to the client. - const s3client = new S3Client({ region: InstanceRegion() }); - const archiveStream = archiver("zip"); - archiveStream.on("error", (error) => { - console.error("Archival encountered an error:", error); - throw new Error(error); - }); - const passthrough = new stream.PassThrough(); - - archiveStream.pipe(passthrough); - for (const key of data.documents.map((d) => d.key)) { - const response = await s3client.send(new GetObjectCommand({ Bucket: imgproxyDestinationBucket, Key: key })); - // :: `response.Body` is a Buffer - console.log(path.basename(key)); - archiveStream.append(response.Body, { name: path.basename(key) }); - } - - archiveStream.finalize(); - - const archiveKey = `archives/${jobid}/archive-${new Date().toISOString()}.zip`; - - const parallelUploads3 = new Upload({ - client: s3client, - queueSize: 4, // optional concurrency configuration - leavePartsOnError: false, // optional manually handle dropped parts - params: { Bucket: imgproxyDestinationBucket, Key: archiveKey, Body: passthrough } - }); - - parallelUploads3.on("httpUploadProgress", (progress) => { - console.log(progress); - }); - - const uploadResult = await parallelUploads3.done(); - //Generate the presigned URL to download it. - const presignedUrl = await getSignedUrl( - s3client, - new GetObjectCommand({ Bucket: imgproxyDestinationBucket, Key: archiveKey }), - { expiresIn: 360 } - ); - - res.json({ success: true, url: presignedUrl }); - //Iterate over them, build the link based on the media type, and return the array. + data = await client.request(GET_DOCUMENTS_BY_IDS, { documentIds: documentids }); } catch (error) { - logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobid, { - jobid, + logger.log("imgproxy-download-error", "ERROR", req.user?.email, jobId, { + jobId, billid, message: error.message, stack: error.stack }); - res.status(400).json({ message: error.message, stack: error.stack }); + return res.status(400).json({ message: error.message }); + } + + const s3client = new S3Client({ region: InstanceRegion() }); + const zipfile = new yazl.ZipFile(); + + const filename = `archive-${jobId || "na"}-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`; + res.setHeader("Content-Type", "application/zip"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + + // Handle zipfile stream errors + zipfile.outputStream.on("error", (err) => { + logger.log("imgproxy-download-zipstream-error", "ERROR", req.user?.email, jobId, { message: err.message, stack: err.stack }); + // Cannot send another response here, just destroy the connection + res.destroy(err); + }); + + zipfile.outputStream.pipe(res); + + try { + for (const doc of data.documents) { + let key = keyStandardize(doc) + let response; + try { + response = await s3client.send( + new GetObjectCommand({ + Bucket: imgproxyDestinationBucket, + Key: key + }) + ); + } catch (err) { + logger.log("imgproxy-download-s3-error", "ERROR", req.user?.email, jobId, { key, message: err.message, stack: err.stack }); + // Optionally, skip this file or add a placeholder file in the zip + continue; + } + // Attach error handler to S3 stream + response.Body.on("error", (err) => { + logger.log("imgproxy-download-s3stream-error", "ERROR", req.user?.email, jobId, { key, message: err.message, stack: err.stack }); + res.destroy(err); + }); + zipfile.addReadStream(response.Body, path.basename(key)); + } + zipfile.end(); + } catch (error) { + logger.log("imgproxy-download-error", "ERROR", req.user?.email, jobId, { + jobId, + billid, + message: error.message, + stack: error.stack + }); + // Cannot send another response here, just destroy the connection + res.destroy(error); } }; -exports.deleteFiles = async (req, res) => { +/** + * Delete Files + * @param req + * @param res + * @returns {Promise<*>} + */ +const deleteFiles = async (req, res) => { //Mark a file for deletion in s3. Lifecycle deletion will actually delete the copy in the future. //Mark as deleted from the documents section of the database. const { ids } = req.body; @@ -232,7 +261,7 @@ exports.deleteFiles = async (req, res) => { (async () => { try { // Delete the original object - const deleteResult = await s3client.send( + await s3client.send( new DeleteObjectCommand({ Bucket: imgproxyDestinationBucket, Key: document.key @@ -250,23 +279,30 @@ exports.deleteFiles = async (req, res) => { const result = await Promise.all(deleteTransactions); const errors = result.filter((d) => d.error); - //Delete only the succesful deletes. + //Delete only the successful deletes. const deleteMutationResult = await client.request(DELETE_MEDIA_DOCUMENTS, { ids: result.filter((t) => !t.error).map((d) => d.id) }); - res.json({ errors, deleteMutationResult }); + return res.json({ errors, deleteMutationResult }); } catch (error) { logger.log("imgproxy-delete-files-error", "ERROR", req.user.email, null, { ids, message: error.message, stack: error.stack }); - res.status(400).json({ message: error.message, stack: error.stack }); + + return res.status(400).json({ message: error.message, stack: error.stack }); } }; -exports.moveFiles = async (req, res) => { +/** + * Move Files + * @param req + * @param res + * @returns {Promise<*>} + */ +const moveFiles = async (req, res) => { const { documents, tojobid } = req.body; try { logger.log("imgproxy-move-files", "DEBUG", req.user.email, null, { documents, tojobid }); @@ -278,7 +314,7 @@ exports.moveFiles = async (req, res) => { (async () => { try { // Copy the object to the new key - const copyresult = await s3client.send( + await s3client.send( new CopyObjectCommand({ Bucket: imgproxyDestinationBucket, CopySource: `${imgproxyDestinationBucket}/${document.from}`, @@ -288,7 +324,7 @@ exports.moveFiles = async (req, res) => { ); // Delete the original object - const deleteResult = await s3client.send( + await s3client.send( new DeleteObjectCommand({ Bucket: imgproxyDestinationBucket, Key: document.from @@ -297,7 +333,12 @@ exports.moveFiles = async (req, res) => { return document; } catch (error) { - return { id: document.id, from: document.from, error: error, bucket: imgproxyDestinationBucket }; + return { + id: document.id, + from: document.from, + error: error, + bucket: imgproxyDestinationBucket + }; } })() ); @@ -307,6 +348,7 @@ exports.moveFiles = async (req, res) => { const errors = result.filter((d) => d.error); let mutations = ""; + result .filter((d) => !d.error) .forEach((d, idx) => { @@ -321,14 +363,16 @@ exports.moveFiles = async (req, res) => { }); const client = req.userGraphQLClient; + if (mutations !== "") { const mutationResult = await client.request(`mutation { ${mutations} }`); - res.json({ errors, mutationResult }); - } else { - res.json({ errors: "No images were succesfully moved on remote server. " }); + + return res.json({ errors, mutationResult }); } + + return res.json({ errors: "No images were successfully moved on remote server. " }); } catch (error) { logger.log("imgproxy-move-files-error", "ERROR", req.user.email, null, { documents, @@ -336,13 +380,24 @@ exports.moveFiles = async (req, res) => { message: error.message, stack: error.stack }); - res.status(400).json({ message: error.message, stack: error.stack }); + + return res.status(400).json({ message: error.message, stack: error.stack }); } }; -function base64UrlEncode(str) { - return Buffer.from(str).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} -function createHmacSha256(data) { - return crypto.createHmac("sha256", imgproxyKey).update(data).digest("base64url"); -} +const keyStandardize = (doc) => { + if (/\.[^/.]+$/.test(doc.key)) { + return doc.key; + } else { + return `${doc.key}.${doc.extension.toLowerCase()}`; + } +}; + + +module.exports = { + generateSignedUploadUrls, + getThumbnailUrls, + downloadFiles, + deleteFiles, + moveFiles +}; diff --git a/server/media/media.js b/server/media/media.js index 06b1c9bb8..7cb8a1b5d 100644 --- a/server/media/media.js +++ b/server/media/media.js @@ -1,42 +1,55 @@ -const path = require("path"); const _ = require("lodash"); const logger = require("../utils/logger"); const client = require("../graphql-client/graphql-client").client; -const queries = require("../graphql-client/queries"); +const determineFileType = require("./util/determineFileType"); +const { DELETE_MEDIA_DOCUMENTS } = require("../graphql-client/queries"); -require("dotenv").config({ - path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) -}); - -var cloudinary = require("cloudinary").v2; +const cloudinary = require("cloudinary").v2; cloudinary.config(process.env.CLOUDINARY_URL); +/** + * @description Creates a signed upload URL for Cloudinary. + * @param req + * @param res + */ const createSignedUploadURL = (req, res) => { logger.log("media-signed-upload", "DEBUG", req.user.email, null, null); res.send(cloudinary.utils.api_sign_request(req.body, process.env.CLOUDINARY_API_SECRET)); }; -exports.createSignedUploadURL = createSignedUploadURL; - +/** + * @description Downloads files from Cloudinary. + * @param req + * @param res + */ const downloadFiles = (req, res) => { const { ids } = req.body; + logger.log("media-bulk-download", "DEBUG", req.user.email, ids, null); const url = cloudinary.utils.download_zip_url({ public_ids: ids, flatten_folders: true }); + res.send(url); }; -exports.downloadFiles = downloadFiles; +/** + * @description Deletes files from Cloudinary and Apollo. + * @param req + * @param res + * @returns {Promise} + */ const deleteFiles = async (req, res) => { const { ids } = req.body; - const types = _.groupBy(ids, (x) => DetermineFileType(x.type)); + + const types = _.groupBy(ids, (x) => determineFileType(x.type)); logger.log("media-bulk-delete", "DEBUG", req.user.email, ids, null); const returns = []; + if (types.image) { //delete images @@ -47,8 +60,8 @@ const deleteFiles = async (req, res) => { ) ); } + if (types.video) { - //delete images returns.push( returns.push( await cloudinary.api.delete_resources( types.video.map((x) => x.key), @@ -56,8 +69,8 @@ const deleteFiles = async (req, res) => { ) ); } + if (types.raw) { - //delete images returns.push( returns.push( await cloudinary.api.delete_resources( types.raw.map((x) => `${x.key}.${x.extension}`), @@ -68,6 +81,7 @@ const deleteFiles = async (req, res) => { // Delete it on apollo. const successfulDeletes = []; + returns.forEach((resType) => { Object.keys(resType.deleted).forEach((key) => { if (resType.deleted[key] === "deleted" || resType.deleted[key] === "not_found") { @@ -77,7 +91,7 @@ const deleteFiles = async (req, res) => { }); try { - const result = await client.request(queries.DELETE_MEDIA_DOCUMENTS, { + const result = await client.request(DELETE_MEDIA_DOCUMENTS, { ids: ids.filter((i) => successfulDeletes.includes(i.key)).map((i) => i.id) }); @@ -91,24 +105,29 @@ const deleteFiles = async (req, res) => { } }; -exports.deleteFiles = deleteFiles; - +/** + * @description Renames keys in Cloudinary and updates the database. + * @param req + * @param res + * @returns {Promise} + */ const renameKeys = async (req, res) => { const { documents, tojobid } = req.body; + logger.log("media-bulk-rename", "DEBUG", req.user.email, null, documents); const proms = []; + documents.forEach((d) => { proms.push( (async () => { try { - const res = { + return { id: d.id, ...(await cloudinary.uploader.rename(d.from, d.to, { - resource_type: DetermineFileType(d.type) + resource_type: determineFileType(d.type) })) }; - return res; } catch (error) { return { id: d.id, from: d.from, error: error }; } @@ -148,18 +167,13 @@ const renameKeys = async (req, res) => { }`); res.json({ errors, mutationResult }); } else { - res.json({ errors: "No images were succesfully moved on remote server. " }); + res.json({ errors: "No images were successfully moved on remote server. " }); } }; -exports.renameKeys = renameKeys; -//Also needs to be updated in upload utility and mobile app. -function DetermineFileType(filetype) { - if (!filetype) return "auto"; - else if (filetype.startsWith("image")) return "image"; - else if (filetype.startsWith("video")) return "video"; - else if (filetype.startsWith("application/pdf")) return "image"; - else if (filetype.startsWith("application")) return "raw"; - - return "auto"; -} +module.exports = { + createSignedUploadURL, + downloadFiles, + deleteFiles, + renameKeys +}; diff --git a/server/media/tests/media-utils.test.js b/server/media/tests/media-utils.test.js new file mode 100644 index 000000000..b25678da1 --- /dev/null +++ b/server/media/tests/media-utils.test.js @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import determineFileType from "../util/determineFileType"; +import base64UrlEncode from "../util/base64UrlEncode"; + +describe("Media Utils", () => { + describe("base64UrlEncode", () => { + it("should encode string to base64url format", () => { + expect(base64UrlEncode("hello world")).toBe("aGVsbG8gd29ybGQ"); + }); + + it('should replace "+" with "-"', () => { + // '+' in base64 appears when encoding specific binary data + expect(base64UrlEncode("hello+world")).toBe("aGVsbG8rd29ybGQ"); + }); + + it('should replace "/" with "_"', () => { + expect(base64UrlEncode("path/to/resource")).toBe("cGF0aC90by9yZXNvdXJjZQ"); + }); + + it('should remove trailing "=" characters', () => { + // Using a string that will produce padding in base64 + expect(base64UrlEncode("padding==")).toBe("cGFkZGluZz09"); + }); + }); + + describe("createHmacSha256", () => { + let createHmacSha256; + const originalEnv = process.env; + + beforeEach(async () => { + vi.resetModules(); + process.env = { ...originalEnv }; + process.env.IMGPROXY_KEY = "test-key"; + + // Dynamically import the module after setting env var + const module = await import("../util/createHmacSha256"); + createHmacSha256 = module.default; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("should create a valid HMAC SHA-256 hash", () => { + const result = createHmacSha256("test-data"); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + it("should produce consistent hashes for the same input", () => { + const hash1 = createHmacSha256("test-data"); + const hash2 = createHmacSha256("test-data"); + expect(hash1).toBe(hash2); + }); + + it("should produce different hashes for different inputs", () => { + const hash1 = createHmacSha256("test-data-1"); + const hash2 = createHmacSha256("test-data-2"); + expect(hash1).not.toBe(hash2); + }); + }); + + describe("determineFileType", () => { + it('should return "auto" when no filetype is provided', () => { + expect(determineFileType()).toBe("auto"); + expect(determineFileType(null)).toBe("auto"); + expect(determineFileType(undefined)).toBe("auto"); + }); + + it('should return "image" for image filetypes', () => { + expect(determineFileType("image/jpeg")).toBe("image"); + expect(determineFileType("image/png")).toBe("image"); + expect(determineFileType("image/gif")).toBe("image"); + }); + + it('should return "video" for video filetypes', () => { + expect(determineFileType("video/mp4")).toBe("video"); + expect(determineFileType("video/quicktime")).toBe("video"); + expect(determineFileType("video/x-msvideo")).toBe("video"); + }); + + it('should return "image" for PDF files', () => { + expect(determineFileType("application/pdf")).toBe("image"); + }); + + it('should return "raw" for other application types', () => { + expect(determineFileType("application/zip")).toBe("raw"); + expect(determineFileType("application/json")).toBe("raw"); + expect(determineFileType("application/msword")).toBe("raw"); + }); + + it('should return "auto" for unrecognized types', () => { + expect(determineFileType("audio/mpeg")).toBe("auto"); + expect(determineFileType("text/html")).toBe("auto"); + expect(determineFileType("unknown-type")).toBe("auto"); + }); + }); +}); diff --git a/server/media/util/base64UrlEncode.js b/server/media/util/base64UrlEncode.js new file mode 100644 index 000000000..24537cb2c --- /dev/null +++ b/server/media/util/base64UrlEncode.js @@ -0,0 +1,9 @@ +/** + * @description Converts a string to a base64url encoded string. + * @param str + * @returns {string} + */ +const base64UrlEncode = (str) => + Buffer.from(str).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + +module.exports = base64UrlEncode; diff --git a/server/media/util/createHmacSha256.js b/server/media/util/createHmacSha256.js new file mode 100644 index 000000000..6be9d6022 --- /dev/null +++ b/server/media/util/createHmacSha256.js @@ -0,0 +1,12 @@ +const crypto = require("crypto"); + +const imgproxyKey = process.env.IMGPROXY_KEY; + +/** + * @description Creates a HMAC SHA-256 hash of the given data. + * @param data + * @returns {string} + */ +const createHmacSha256 = (data) => crypto.createHmac("sha256", imgproxyKey).update(data).digest("base64url"); + +module.exports = createHmacSha256; diff --git a/server/media/util/determineFileType.js b/server/media/util/determineFileType.js new file mode 100644 index 000000000..9bd8a4732 --- /dev/null +++ b/server/media/util/determineFileType.js @@ -0,0 +1,17 @@ +/** + * @description Determines the file type based on the filetype string. + * @note Also needs to be updated in the mobile app utility. + * @param filetype + * @returns {string} + */ +const determineFileType = (filetype) => { + if (!filetype) return "auto"; + else if (filetype.startsWith("image")) return "image"; + else if (filetype.startsWith("video")) return "video"; + else if (filetype.startsWith("application/pdf")) return "image"; + else if (filetype.startsWith("application")) return "raw"; + + return "auto"; +}; + +module.exports = determineFileType; diff --git a/server/middleware/partsManagementIntegrationMiddleware.js b/server/middleware/partsManagementIntegrationMiddleware.js new file mode 100644 index 000000000..b564543a4 --- /dev/null +++ b/server/middleware/partsManagementIntegrationMiddleware.js @@ -0,0 +1,23 @@ +/** + * Middleware to check if the request is authorized for Parts Management Integration. + * @param req + * @param res + * @param next + * @returns {*} + */ +const partsManagementIntegrationMiddleware = (req, res, next) => { + const secret = process.env.PARTS_MANAGEMENT_INTEGRATION_SECRET; + if (typeof secret !== "string" || secret.length === 0) { + return res.status(500).send("Server misconfiguration"); + } + + const headerValue = req.headers["parts-management-integration-secret"]; + if (typeof headerValue !== "string" || headerValue.trim() !== secret) { + return res.status(401).send("Unauthorized"); + } + + req.isPartsManagementIntegrationAuthorized = true; + next(); +}; + +module.exports = partsManagementIntegrationMiddleware; diff --git a/server/middleware/vsstaIntegrationMiddleware.js b/server/middleware/vsstaIntegrationMiddleware.js new file mode 100644 index 000000000..081c41179 --- /dev/null +++ b/server/middleware/vsstaIntegrationMiddleware.js @@ -0,0 +1,20 @@ +/** + * VSSTA Integration Middleware + * Fails closed if the env var is missing or empty, and strictly compares header. + */ +const vsstaIntegrationMiddleware = (req, res, next) => { + const secret = process.env.VSSTA_INTEGRATION_SECRET; + if (typeof secret !== "string" || secret.length === 0) { + return res.status(500).send("Server misconfiguration"); + } + + const headerValue = req.headers["vssta-integration-secret"]; + if (typeof headerValue !== "string" || headerValue.trim() !== secret) { + return res.status(401).send("Unauthorized"); + } + + req.isVsstaIntegrationAuthorized = true; + next(); +}; + +module.exports = vsstaIntegrationMiddleware; diff --git a/server/notifications/autoAddWatchers.js b/server/notifications/autoAddWatchers.js new file mode 100644 index 000000000..704ca2550 --- /dev/null +++ b/server/notifications/autoAddWatchers.js @@ -0,0 +1,132 @@ +/** + * @module autoAddWatchers + * @description + * This module handles automatically adding watchers to new jobs based on the notifications_autoadd + * boolean field in the associations table and the notification_followers JSON field in the bodyshops table. + * It ensures users are not added twice and logs the process. + */ + +const { client: gqlClient } = require("../graphql-client/graphql-client"); +const { isEmpty } = require("lodash"); +const { + GET_JOB_WATCHERS_MINIMAL, + GET_NOTIFICATION_WATCHERS, + INSERT_JOB_WATCHERS +} = require("../graphql-client/queries"); + +// If true, the user who commits the action will NOT receive notifications; if false, they will. +const FILTER_SELF_FROM_WATCHERS = process.env?.FILTER_SELF_FROM_WATCHERS !== "false"; + +/** + * Adds watchers to a new job based on notifications_autoadd and notification_followers. + * + * @param {Object} req - The request object containing event data and logger. + * @returns {Promise} Resolves when watchers are added or if no action is needed. + * @throws {Error} If critical data (e.g., jobId, shopId) is missing. + */ +const autoAddWatchers = async (req) => { + const { event, trigger } = req.body; + const { + logger, + sessionUtils: { getBodyshopFromRedis } + } = req; + + // Validate that this is an INSERT event, bail + if (trigger?.name !== "notifications_jobs_autoadd" || event.op !== "INSERT" || event.data.old) { + return; + } + + const jobId = event?.data?.new?.id; + const shopId = event?.data?.new?.shopid; + const roNumber = event?.data?.new?.ro_number || "unknown"; + + if (!jobId || !shopId) { + throw new Error(`Missing jobId (${jobId}) or shopId (${shopId}) for auto-add watchers`); + } + + const hasuraUserRole = event?.session_variables?.["x-hasura-role"]; + const hasuraUserId = event?.session_variables?.["x-hasura-user-id"]; + + try { + // Fetch bodyshop data from Redis + const bodyshopData = await getBodyshopFromRedis(shopId); + let notificationFollowers = bodyshopData?.notification_followers; + + // Bail if notification_followers is missing or not an array + if (!notificationFollowers || !Array.isArray(notificationFollowers)) { + return; + } + + // Execute queries in parallel + const [notificationData, existingWatchersData] = await Promise.all([ + gqlClient.request(GET_NOTIFICATION_WATCHERS, { + shopId, + employeeIds: notificationFollowers.filter((id) => id) + }), + gqlClient.request(GET_JOB_WATCHERS_MINIMAL, { jobid: jobId }) + ]); + + // Get users with notifications_autoadd: true + const autoAddUsers = + notificationData?.associations?.map((assoc) => ({ + email: assoc.useremail, + associationId: assoc.id + })) || []; + + // Get users from notification_followers + const followerEmails = + notificationData?.employees + ?.filter((e) => e.user_email) + ?.map((e) => ({ + email: e.user_email, + associationId: null + })) || []; + + // Combine and deduplicate emails (use email as the unique key) + const usersToAdd = [...autoAddUsers, ...followerEmails].reduce((acc, user) => { + if (!acc.some((u) => u.email === user.email)) { + acc.push(user); + } + return acc; + }, []); + + if (isEmpty(usersToAdd)) { + return; + } + + // Check existing watchers to avoid duplicates + const existingWatcherEmails = existingWatchersData?.job_watchers?.map((w) => w.user_email) || []; + + // Filter out already existing watchers and optionally the user who created the job + const newWatchers = usersToAdd + .filter((user) => !existingWatcherEmails.includes(user.email)) + .filter((user) => { + if (FILTER_SELF_FROM_WATCHERS && hasuraUserRole === "user") { + const userData = existingWatchersData?.job_watchers?.find((w) => w.user?.authid === hasuraUserId); + return userData ? user.email !== userData.user_email : true; + } + return true; + }) + .map((user) => ({ + jobid: jobId, + user_email: user.email + })); + + if (isEmpty(newWatchers)) { + return; + } + + // Insert new watchers + await gqlClient.request(INSERT_JOB_WATCHERS, { watchers: newWatchers }); + } catch (error) { + logger.log("Error adding auto-add watchers", "error", "notifications", null, { + message: error?.message, + stack: error?.stack, + jobId, + roNumber + }); + throw error; // Re-throw to ensure the error is logged in the handler + } +}; + +module.exports = { autoAddWatchers }; diff --git a/server/notifications/eventHandlers.js b/server/notifications/eventHandlers.js index eeb86981d..87a1dceed 100644 --- a/server/notifications/eventHandlers.js +++ b/server/notifications/eventHandlers.js @@ -6,6 +6,7 @@ */ const scenarioParser = require("./scenarioParser"); +const { autoAddWatchers } = require("./autoAddWatchers"); // New module /** * Processes a notification event by invoking the scenario parser. @@ -144,15 +145,70 @@ const handleNotesChange = async (req, res) => const handlePaymentsChange = async (req, res) => processNotificationEvent(req, res, "req.body.event.new.jobid", "Payments Changed Notification Event Handled."); +/** + * Handle task socket emit. + * @param req + */ +const handleTaskSocketEmit = (req) => { + const { + logger, + ioRedis, + ioHelpers: { getBodyshopRoom } + } = req; + const event = req.body.event; + const op = event.op; + let taskData; + let type; + let bodyshopId; + + if (op === "INSERT") { + taskData = event.data.new; + if (taskData.deleted) { + logger.log("tasks-event-insert-deleted", "warn", "notifications", null, { id: taskData.id }); + } else { + type = "task-created"; + bodyshopId = taskData.bodyshopid; + } + } else if (op === "UPDATE") { + const newData = event.data.new; + const oldData = event.data.old; + taskData = newData; + bodyshopId = newData.bodyshopid; + + if (newData.deleted && !oldData.deleted) { + type = "task-deleted"; + taskData = { id: newData.id, assigned_to: newData.assigned_to }; + } else if (!newData.deleted && oldData.deleted) { + type = "task-created"; + } else if (!newData.deleted) { + type = "task-updated"; + } + } else { + logger.log("tasks-event-unknown-op", "warn", "notifications", null, { op }); + } + + if (bodyshopId && ioRedis && type) { + const room = getBodyshopRoom(bodyshopId); + ioRedis.to(room).emit("bodyshop-message", { type, payload: taskData }); + logger.log("tasks-event-emitted", "info", "notifications", null, { type, bodyshopId }); + } else if (type) { + logger.log("tasks-event-missing-data", "error", "notifications", null, { bodyshopId, hasIo: !!ioRedis, type }); + } +}; + /** * Handle tasks change notifications. + * Note: this also handles task center notifications. * * @param {Object} req - Express request object. * @param {Object} res - Express response object. * @returns {Promise} JSON response with a success message. */ -const handleTasksChange = async (req, res) => +const handleTasksChange = async (req, res) => { + // Handle Notification Event processNotificationEvent(req, res, "req.body.event.new.jobid", "Tasks Notifications Event Handled."); + handleTaskSocketEmit(req); +}; /** * Handle time tickets change notifications. @@ -185,6 +241,27 @@ const handlePartsDispatchChange = (req, res) => res.status(200).json({ message: */ const handlePartsOrderChange = (req, res) => res.status(200).json({ message: "Parts Order change handled." }); +/** + * Handle auto-add watchers for new jobs. + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object. + * @returns {Promise} JSON response with a success message. + */ +const handleAutoAddWatchers = async (req, res) => { + const { logger } = req; + + // Call autoAddWatchers but don't await it; log any error that occurs. + autoAddWatchers(req).catch((error) => { + logger.log("auto-add-watchers-error", "error", "notifications", null, { + message: error?.message, + stack: error?.stack + }); + }); + + return res.status(200).json({ message: "Auto-Add Watchers Event Handled." }); +}; + module.exports = { handleJobsChange, handleBillsChange, @@ -195,5 +272,6 @@ module.exports = { handlePartsOrderChange, handlePaymentsChange, handleTasksChange, - handleTimeTicketsChange + handleTimeTicketsChange, + handleAutoAddWatchers }; diff --git a/server/notifications/queues/emailQueue.js b/server/notifications/queues/emailQueue.js index ff6702635..7d965cb69 100644 --- a/server/notifications/queues/emailQueue.js +++ b/server/notifications/queues/emailQueue.js @@ -133,11 +133,19 @@ const loadEmailQueue = async ({ pubClient, logger }) => { subHeader: `Dear ${firstName},`, dateLine: moment().tz(timezone).format("MM/DD/YYYY hh:mm a"), body: ` -

    There have been updates to job ${jobRoNumber || "N/A"} at ${bodyShopName}:


    -
      - ${messages.map((msg) => `
    • ${msg}
    • `).join("")} -


    -

    Please check the job for more details.

    +

    There have been updates to job ${jobRoNumber || "N/A"} at ${bodyShopName}:

    +
+ + +
+
    + ${messages.map((msg) => `
  • ${msg}
  • `).join("")} +
+
+ +
+

Please check the job for more details.

` }); await sendTaskEmail({ @@ -226,6 +234,7 @@ const getQueue = () => { * @param {Object} options.logger - Logger instance for logging dispatch events. * @returns {Promise} Resolves when all notifications are added to the queue. */ +// eslint-disable-next-line no-unused-vars const dispatchEmailsToQueue = async ({ emailsToDispatch, logger }) => { const emailAddQueue = getQueue(); diff --git a/server/notifications/scenarioBuilders.js b/server/notifications/scenarioBuilders.js index b3f4d0fd2..d1bdb22a0 100644 --- a/server/notifications/scenarioBuilders.js +++ b/server/notifications/scenarioBuilders.js @@ -182,7 +182,7 @@ const newMediaAddedReassignedBuilder = (data) => { : data.changedFields?.jobid && data.changedFields.jobid.old !== data.changedFields.jobid.new ? "moved to this job" : "updated"; - const body = `An ${mediaType} has been ${action}.`; + const body = `A ${mediaType} has been ${action}.`; return buildNotification(data, "notifications.job.newMediaAdded", body, { mediaType, diff --git a/server/notifications/scenarioParser.js b/server/notifications/scenarioParser.js index ddf1ff103..aebec8205 100644 --- a/server/notifications/scenarioParser.js +++ b/server/notifications/scenarioParser.js @@ -63,7 +63,9 @@ const scenarioParser = async (req, jobIdField) => { } if (!jobId) { - logger.log(`No jobId found using path "${jobIdField}", skipping notification parsing`, "info", "notifications"); + if (process?.env?.NODE_ENV === "development") { + logger.log(`No jobId found using path "${jobIdField}", skipping notification parsing`, "info", "notifications"); + } return; } @@ -88,7 +90,9 @@ const scenarioParser = async (req, jobIdField) => { // Exit early if no job watchers are found for this job if (isEmpty(jobWatchers)) { - logger.log(`No watchers found for jobId "${jobId}", skipping notification parsing`, "info", "notifications"); + if (process?.env?.NODE_ENV === "development") { + logger.log(`No watchers found for jobId "${jobId}", skipping notification parsing`, "info", "notifications"); + } return; } @@ -130,11 +134,13 @@ const scenarioParser = async (req, jobIdField) => { // Exit early if no matching scenarios are identified if (isEmpty(matchingScenarios)) { - logger.log( - `No matching scenarios found for jobId "${jobId}", skipping notification dispatch`, - "info", - "notifications" - ); + if (process?.env?.NODE_ENV === "development") { + logger.log( + `No matching scenarios found for jobId "${jobId}", skipping notification dispatch`, + "info", + "notifications" + ); + } return; } @@ -157,11 +163,13 @@ const scenarioParser = async (req, jobIdField) => { // Exit early if no notification associations are found if (isEmpty(associationsData?.associations)) { - logger.log( - `No notification associations found for jobId "${jobId}", skipping notification dispatch`, - "info", - "notifications" - ); + if (process?.env?.NODE_ENV === "development") { + logger.log( + `No notification associations found for jobId "${jobId}", skipping notification dispatch`, + "info", + "notifications" + ); + } return; } @@ -196,11 +204,13 @@ const scenarioParser = async (req, jobIdField) => { // Exit early if no scenarios have eligible watchers after filtering if (isEmpty(finalScenarioData?.matchingScenarios)) { - logger.log( - `No eligible watchers after filtering for jobId "${jobId}", skipping notification dispatch`, - "info", - "notifications" - ); + if (process?.env?.NODE_ENV === "development") { + logger.log( + `No eligible watchers after filtering for jobId "${jobId}", skipping notification dispatch`, + "info", + "notifications" + ); + } return; } @@ -259,7 +269,9 @@ const scenarioParser = async (req, jobIdField) => { } if (isEmpty(scenariosToDispatch)) { - logger.log(`No scenarios to dispatch for jobId "${jobId}" after building`, "info", "notifications"); + if (process?.env?.NODE_ENV === "development") { + logger.log(`No scenarios to dispatch for jobId "${jobId}" after building`, "info", "notifications"); + } return; } diff --git a/server/opensearch/os-handler.js b/server/opensearch/os-handler.js index 48310c4f2..3292233c9 100644 --- a/server/opensearch/os-handler.js +++ b/server/opensearch/os-handler.js @@ -64,7 +64,7 @@ async function OpenSearchUpdateHandler(req, res) { document = pick(req.body.event.data.new, ["id", "ownr_fn", "ownr_ln", "ownr_co_nm", "ownr_ph1", "ownr_ph2"]); document.bodyshopid = req.body.event.data.new.shopid; break; - case "bills": + case "bills": { const bill = await client.request( `query ADMIN_GET_BILL_BY_ID($billId: uuid!) { bills_by_pk(id: $billId) { @@ -97,7 +97,8 @@ async function OpenSearchUpdateHandler(req, res) { bodyshopid: bill.bills_by_pk.job.shopid }; break; - case "payments": + } + case "payments": { //Query to get the job and RO number const payment = await client.request( @@ -141,6 +142,7 @@ async function OpenSearchUpdateHandler(req, res) { bodyshopid: payment.payments_by_pk.job.shopid }; break; + } } const payload = { id: req.body.event.data.new.id, @@ -255,6 +257,7 @@ async function OpenSearchSearchHandler(req, res) { "*ownr_co_nm^8", "*ownr_ph1^8", "*ownr_ph2^8", + "*vendor.name^8", "*comment^6" // "*" ] diff --git a/server/payroll/pay-all.js b/server/payroll/pay-all.js index 03c6fbee8..33614f656 100644 --- a/server/payroll/pay-all.js +++ b/server/payroll/pay-all.js @@ -1,11 +1,10 @@ const Dinero = require("dinero.js"); const queries = require("../graphql-client/queries"); -const GraphQLClient = require("graphql-request").GraphQLClient; const _ = require("lodash"); const rdiff = require("recursive-diff"); const logger = require("../utils/logger"); -const { json } = require("body-parser"); + // Dinero.defaultCurrency = "USD"; // Dinero.globalLocale = "en-CA"; Dinero.globalRoundingMode = "HALF_EVEN"; diff --git a/server/render/inlinecss.js b/server/render/inlinecss.js index 74700b210..bf8d45ba4 100644 --- a/server/render/inlinecss.js +++ b/server/render/inlinecss.js @@ -1,16 +1,11 @@ -const path = require("path"); -require("dotenv").config({ - path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) -}); -const logger = require("../utils/logger"); -//const inlineCssTool = require("inline-css"); const juice = require("juice"); -exports.inlinecss = async (req, res) => { - //Perform request validation +exports.inlineCSS = async (req, res) => { + const { logger } = req; + const { html } = req.body; + logger.log("email-inline-css", "DEBUG", req.user.email, null, null); - const { html, url } = req.body; try { const inlinedHtml = juice(html, { applyAttributesTableElements: false, @@ -24,15 +19,4 @@ exports.inlinecss = async (req, res) => { }); res.send(error.message); } - - // inlineCssTool(html, { url: url }) - // .then((inlinedHtml) => { - // res.send(inlinedHtml); - // }) - // .catch((error) => { - // logger.log("email-inline-css-error", "ERROR", req.user.email, null, { - // error - // }); - - // }); }; diff --git a/server/routes/adminRoutes.js b/server/routes/adminRoutes.js index 0d62d27a6..909f11344 100644 --- a/server/routes/adminRoutes.js +++ b/server/routes/adminRoutes.js @@ -2,7 +2,7 @@ const express = require("express"); const router = express.Router(); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); const { createAssociation, createShop, updateShop, updateCounter } = require("../admin/adminops"); -const { updateUser, getUser, createUser } = require("../firebase/firebase-handler"); +const { updateUser, getUser, createUser, getWelcomeEmail, getResetLink } = require("../firebase/firebase-handler"); const validateAdminMiddleware = require("../middleware/validateAdminMiddleware"); router.use(validateFirebaseIdTokenMiddleware); @@ -15,5 +15,7 @@ router.post("/updatecounter", updateCounter); router.post("/updateuser", updateUser); router.post("/getuser", getUser); router.post("/createuser", createUser); +router.post("/sendwelcome", getWelcomeEmail); +router.post("/resetlink", getResetLink); module.exports = router; diff --git a/server/routes/dataRoutes.js b/server/routes/dataRoutes.js index 788574074..8e7bc04fd 100644 --- a/server/routes/dataRoutes.js +++ b/server/routes/dataRoutes.js @@ -1,11 +1,13 @@ const express = require("express"); const router = express.Router(); -const { autohouse, claimscorp, chatter, kaizen, usageReport } = require("../data/data"); +const { autohouse, claimscorp, chatter, kaizen, usageReport, podium, carfax } = require("../data/data"); router.post("/ah", autohouse); router.post("/cc", claimscorp); router.post("/chatter", chatter); router.post("/kaizen", kaizen); router.post("/usagereport", usageReport); +router.post("/podium", podium); +router.post("/carfax", carfax); module.exports = router; diff --git a/server/routes/intergrationRoutes.js b/server/routes/intergrationRoutes.js new file mode 100644 index 000000000..46826d28e --- /dev/null +++ b/server/routes/intergrationRoutes.js @@ -0,0 +1,27 @@ +const express = require("express"); +const router = express.Router(); + +// Pull secrets from env +const { VSSTA_INTEGRATION_SECRET, PARTS_MANAGEMENT_INTEGRATION_SECRET } = process.env; + +// Only load VSSTA routes if the secret is set +if (typeof VSSTA_INTEGRATION_SECRET === "string" && VSSTA_INTEGRATION_SECRET.length > 0) { + const vsstaIntegration = require("../integrations/VSSTA/vsstaIntegrationRoute"); + const vsstaMiddleware = require("../middleware/vsstaIntegrationMiddleware"); + + router.post("/vssta", vsstaMiddleware, vsstaIntegration); +} else { + console.warn("VSSTA_INTEGRATION_SECRET is not set — skipping /vssta integration route"); +} + +// Only load Parts Management routes if that secret is set +if (typeof PARTS_MANAGEMENT_INTEGRATION_SECRET === "string" && PARTS_MANAGEMENT_INTEGRATION_SECRET.length > 0) { + const partsManagementProvisioning = require("../integrations/partsManagement/partsManagementProvisioning"); + const partsManagementIntegrationMiddleware = require("../middleware/partsManagementIntegrationMiddleware"); + + router.post("/parts-management/provision", partsManagementIntegrationMiddleware, partsManagementProvisioning); +} else { + console.warn("PARTS_MANAGEMENT_INTEGRATION_SECRET is not set — skipping /parts-management/provision route"); +} + +module.exports = router; diff --git a/server/routes/jobRoutes.js b/server/routes/jobRoutes.js index aab3e8823..e7c747907 100644 --- a/server/routes/jobRoutes.js +++ b/server/routes/jobRoutes.js @@ -1,6 +1,5 @@ const express = require("express"); const router = express.Router(); -const job = require("../job/job"); const ppc = require("../ccc/partspricechange"); const { partsScan } = require("../parts-scan/parts-scan"); const eventAuthorizationMiddleware = require("../middleware/eventAuthorizationMIddleware"); diff --git a/server/routes/miscellaneousRoutes.js b/server/routes/miscellaneousRoutes.js index aeb59af93..b0360d488 100644 --- a/server/routes/miscellaneousRoutes.js +++ b/server/routes/miscellaneousRoutes.js @@ -3,7 +3,6 @@ const router = express.Router(); const logger = require("../../server/utils/logger"); const sendEmail = require("../email/sendemail"); const data = require("../data/data"); -const bodyParser = require("body-parser"); const ioevent = require("../ioevent/ioevent"); const taskHandler = require("../tasks/tasks"); const os = require("../opensearch/os-handler"); @@ -123,7 +122,7 @@ router.post("/ioevent", ioevent.default); // Email router.post("/sendemail", validateFirebaseIdTokenMiddleware, sendEmail.sendEmail); -router.post("/emailbounce", bodyParser.text(), sendEmail.emailBounce); +router.post("/emailbounce", express.text(), sendEmail.emailBounce); // Tasks Email Handler router.post("/tasks-assigned-handler", eventAuthorizationMiddleware, taskAssignedEmail); @@ -139,6 +138,9 @@ router.post("/canvastest", validateFirebaseIdTokenMiddleware, canvastest); // Alert Check router.post("/alertcheck", eventAuthorizationMiddleware, alertCheck); +//EMS Upload +router.post("/emsupload", validateFirebaseIdTokenMiddleware, data.emsUpload); + // Redis Cache Routes router.post("/bodyshop-cache", eventAuthorizationMiddleware, updateBodyshopCache); diff --git a/server/routes/notificationsRoutes.js b/server/routes/notificationsRoutes.js index 0d47882b1..9e3709a79 100644 --- a/server/routes/notificationsRoutes.js +++ b/server/routes/notificationsRoutes.js @@ -12,7 +12,8 @@ const { handleNotesChange, handlePaymentsChange, handleDocumentsChange, - handleJobLinesChange + handleJobLinesChange, + handleAutoAddWatchers } = require("../notifications/eventHandlers"); const router = express.Router(); @@ -33,5 +34,6 @@ router.post("/events/handleNotesChange", eventAuthorizationMiddleware, handleNot router.post("/events/handlePaymentsChange", eventAuthorizationMiddleware, handlePaymentsChange); router.post("/events/handleDocumentsChange", eventAuthorizationMiddleware, handleDocumentsChange); router.post("/events/handleJobLinesChange", eventAuthorizationMiddleware, handleJobLinesChange); +router.post("/events/handleAutoAdd", eventAuthorizationMiddleware, handleAutoAddWatchers); module.exports = router; diff --git a/server/routes/renderRoutes.js b/server/routes/renderRoutes.js index 613e6b0b5..41b6cf6aa 100644 --- a/server/routes/renderRoutes.js +++ b/server/routes/renderRoutes.js @@ -1,12 +1,12 @@ const express = require("express"); const router = express.Router(); -const { inlinecss } = require("../render/inlinecss"); +const { inlineCSS } = require("../render/inlinecss"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); const { canvas } = require("../render/canvas-handler"); const validateCanvasInputMiddleware = require("../middleware/validateCanvasInputMiddleware"); // Define the route for inline CSS rendering -router.post("/inlinecss", validateFirebaseIdTokenMiddleware, inlinecss); +router.post("/inlinecss", validateFirebaseIdTokenMiddleware, inlineCSS); router.post("/canvas-skia", validateFirebaseIdTokenMiddleware, validateCanvasInputMiddleware, canvas); router.post("/canvas", validateFirebaseIdTokenMiddleware, validateCanvasInputMiddleware, canvas); diff --git a/server/routes/smsRoutes.js b/server/routes/smsRoutes.js index 1b169747d..c09cc1632 100644 --- a/server/routes/smsRoutes.js +++ b/server/routes/smsRoutes.js @@ -7,7 +7,7 @@ const { status, markConversationRead } = require("../sms/status"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); // Twilio Webhook Middleware for production -// TODO: Look into this because it technically is never validating anything +// TODO: This is never actually doing anything, we should probably verify const twilioWebhookMiddleware = twilio.webhook({ validate: process.env.NODE_ENV === "PRODUCTION" }); router.post("/receive", twilioWebhookMiddleware, receive); diff --git a/server/routes/ssoRoutes.js b/server/routes/ssoRoutes.js new file mode 100644 index 000000000..aaae869ea --- /dev/null +++ b/server/routes/ssoRoutes.js @@ -0,0 +1,11 @@ +const express = require("express"); +const router = express.Router(); +const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); +const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware"); +const { cannySsoHandler } = require("../sso/canny"); + +router.use(validateFirebaseIdTokenMiddleware); + +router.post("/canny", withUserGraphQLClientMiddleware, cannySsoHandler); + +module.exports = router; diff --git a/server/sms/receive.js b/server/sms/receive.js index f08cf727e..e51007726 100644 --- a/server/sms/receive.js +++ b/server/sms/receive.js @@ -1,17 +1,36 @@ -const path = require("path"); -require("dotenv").config({ - path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) -}); - const client = require("../graphql-client/graphql-client").client; -const queries = require("../graphql-client/queries"); +const { + FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, + UNARCHIVE_CONVERSATION, + CREATE_CONVERSATION, + INSERT_MESSAGE, + CHECK_PHONE_NUMBER_OPT_OUT, + DELETE_PHONE_NUMBER_OPT_OUT, + INSERT_PHONE_NUMBER_OPT_OUT +} = require("../graphql-client/queries"); const { phone } = require("phone"); const { admin } = require("../firebase/firebase-handler"); -const logger = require("../utils/logger"); const InstanceManager = require("../utils/instanceMgr").default; -exports.receive = async (req, res) => { +// Note: When we handle different languages, we might need to adjust these keywords accordingly. +const optInKeywords = ["START", "YES", "UNSTOP"]; +const optOutKeywords = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT", "REVOKE", "OPTOUT"]; + +// System Message text, will also need to be localized if we support multiple languages +const systemMessageOptions = { + optIn: "Customer has opted-in", + optOut: "Customer has opted-out" +}; + +/** + * Receive SMS messages from Twilio and process them + * @param req + * @param res + * @returns {Promise<*>} + */ +const receive = async (req, res) => { const { + logger, ioRedis, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } } = req; @@ -20,7 +39,7 @@ exports.receive = async (req, res) => { msid: req.body.SmsMessageSid, text: req.body.Body, image: !!req.body.MediaUrl0, - image_path: generateMediaArray(req.body) + image_path: generateMediaArray(req.body, logger) }; logger.log("sms-inbound", "DEBUG", "api", null, loggerData); @@ -35,7 +54,7 @@ exports.receive = async (req, res) => { try { // Step 1: Find the bodyshop and existing conversation - const response = await client.request(queries.FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, { + const response = await client.request(FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, { mssid: req.body.MessagingServiceSid, phone: phone(req.body.From).phoneNumber }); @@ -45,37 +64,27 @@ exports.receive = async (req, res) => { } const bodyshop = response.bodyshops[0]; + const normalizedPhone = phone(req.body.From).phoneNumber.replace(/^\+1/, ""); // Normalize phone number (remove +1 for CA numbers) + const messageText = (req.body.Body || "").trim().toUpperCase(); - // Sort conversations by `updated_at` (or `created_at`) and pick the last one + // Step 2: Process conversation const sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const existingConversation = sortedConversations.length ? sortedConversations[sortedConversations.length - 1] : null; let conversationid; - let newMessage = { - msid: req.body.SmsMessageSid, - text: req.body.Body, - image: !!req.body.MediaUrl0, - image_path: generateMediaArray(req.body), - isoutbound: false, - userid: null // Add additional fields as necessary - }; if (existingConversation) { - // Use the existing conversation conversationid = existingConversation.id; - - // Unarchive the conversation if necessary if (existingConversation.archived) { - await client.request(queries.UNARCHIVE_CONVERSATION, { + await client.request(UNARCHIVE_CONVERSATION, { id: conversationid, archived: false }); } } else { - // Create a new conversation - const newConversationResponse = await client.request(queries.CREATE_CONVERSATION, { + const newConversationResponse = await client.request(CREATE_CONVERSATION, { conversation: { bodyshopid: bodyshop.id, phone_num: phone(req.body.From).phoneNumber, @@ -86,13 +95,137 @@ exports.receive = async (req, res) => { conversationid = createdConversation.id; } - // Ensure `conversationid` is added to the message - newMessage.conversationid = conversationid; + // Step 3: Handle opt-in or opt-out keywords + let systemMessageText = ""; + let socketEventType = ""; - // Step 3: Insert the message into the conversation - const insertresp = await client.request(queries.INSERT_MESSAGE, { + if (optInKeywords.includes(messageText) || optOutKeywords.includes(messageText)) { + // Check if the phone number is in phone_number_opt_out + const optOutCheck = await client.request(CHECK_PHONE_NUMBER_OPT_OUT, { + bodyshopid: bodyshop.id, + phone_number: normalizedPhone + }); + + // Opt In + if (optInKeywords.includes(messageText)) { + // Handle opt-in + if (optOutCheck.phone_number_opt_out.length > 0) { + // Phone number is opted out; delete the record + const deleteResponse = await client.request(DELETE_PHONE_NUMBER_OPT_OUT, { + bodyshopid: bodyshop.id, + phone_number: normalizedPhone + }); + + logger.log("sms-opt-in-success", "INFO", "api", null, { + msid: req.body.SmsMessageSid, + bodyshopid: bodyshop.id, + phone_number: normalizedPhone, + affected_rows: deleteResponse.delete_phone_number_opt_out.affected_rows + }); + + systemMessageText = systemMessageOptions.optIn; + socketEventType = "phone-number-opted-in"; + } + } + // Opt Out + else if (optOutKeywords.includes(messageText)) { + // Handle opt-out + if (optOutCheck.phone_number_opt_out.length === 0) { + // Phone number is not opted out; insert a new record + const now = new Date().toISOString(); + const optOutInput = { + bodyshopid: bodyshop.id, + phone_number: normalizedPhone, + created_at: now, + updated_at: now + }; + + const insertResponse = await client.request(INSERT_PHONE_NUMBER_OPT_OUT, { + optOutInput: [optOutInput] + }); + + logger.log("sms-opt-out-success", "INFO", "api", null, { + msid: req.body.SmsMessageSid, + bodyshopid: bodyshop.id, + phone_number: normalizedPhone, + affected_rows: insertResponse.insert_phone_number_opt_out.affected_rows + }); + + systemMessageText = systemMessageOptions.optOut; + socketEventType = "phone-number-opted-out"; + } + } + + // Insert system message if an opt-in or opt-out action was taken + if (systemMessageText) { + const systemMessage = { + msid: `SYS_${req.body.SmsMessageSid}_${Date.now()}`, // Unique ID for system message + text: systemMessageText, + conversationid, + isoutbound: false, + userid: null, + image: false, + image_path: null, + is_system: true + }; + + const systemMessageResponse = await client.request(INSERT_MESSAGE, { + msg: systemMessage, + conversationid + }); + + const insertedSystemMessage = systemMessageResponse.insert_messages.returning[0]; + + // Emit WebSocket events for system message + const broadcastRoom = getBodyshopRoom(bodyshop.id); + const conversationRoom = getBodyshopConversationRoom({ + bodyshopId: bodyshop.id, + conversationId: conversationid + }); + + const systemPayload = { + isoutbound: false, + conversationId: conversationid, + updated_at: insertedSystemMessage.updated_at, + msid: insertedSystemMessage.msid, + existingConversation: !!existingConversation, + newConversation: !existingConversation ? insertedSystemMessage.conversation : null + }; + + ioRedis.to(broadcastRoom).emit("new-message-summary", { + ...systemPayload, + summary: true + }); + + ioRedis.to(conversationRoom).emit("new-message-detailed", { + newMessage: insertedSystemMessage, + ...systemPayload, + summary: false + }); + + // Emit opt-in or opt-out event + ioRedis.to(broadcastRoom).emit(socketEventType, { + bodyshopid: bodyshop.id, + phone_number: normalizedPhone + }); + } + } + + // Step 4: Insert the original message + const newMessage = { + msid: req.body.SmsMessageSid, + text: req.body.Body, + image: !!req.body.MediaUrl0, + image_path: generateMediaArray(req.body, logger), + isoutbound: false, + userid: null, + conversationid, + is_system: false + }; + + const insertresp = await client.request(INSERT_MESSAGE, { msg: newMessage, - conversationid: conversationid + conversationid }); const message = insertresp?.insert_messages?.returning?.[0]; @@ -102,8 +235,7 @@ exports.receive = async (req, res) => { throw new Error("Conversation data is missing from the response."); } - // Step 4: Notify clients through Redis - const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id); + // Step 5: Notify clients for original message const conversationRoom = getBodyshopConversationRoom({ bodyshopId: conversation.bodyshop.id, conversationId: conversation.id @@ -113,9 +245,11 @@ exports.receive = async (req, res) => { isoutbound: false, conversationId: conversation.id, updated_at: message.updated_at, - msid: message.sid + msid: message.msid }; + const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id); + ioRedis.to(broadcastRoom).emit("new-message-summary", { ...commonPayload, existingConversation: !!existingConversation, @@ -131,13 +265,13 @@ exports.receive = async (req, res) => { summary: false }); - // Step 5: Send FCM notification + // Step 6: Send FCM notification const fcmresp = await admin.messaging().send({ topic: `${message.conversation.bodyshop.imexshopid}-messaging`, notification: { title: InstanceManager({ imex: `ImEX Online Message - ${message.conversation.phone_num}`, - rome: `Rome Online Message - ${message.conversation.phone_num}`, + rome: `Rome Online Message - ${message.conversation.phone_num}` }), body: message.image_path ? `Image ${message.text}` : message.text }, @@ -157,11 +291,17 @@ exports.receive = async (req, res) => { res.status(200).send(""); } catch (e) { - handleError(req, e, res, "RECEIVE_MESSAGE"); + handleError(req, e, res, "RECEIVE_MESSAGE", logger); } }; -const generateMediaArray = (body) => { +/** + * Generate media array from the request body + * @param body + * @param logger + * @returns {null|*[]} + */ +const generateMediaArray = (body, logger) => { const { NumMedia } = body; if (parseInt(NumMedia) > 0) { const ret = []; @@ -174,12 +314,20 @@ const generateMediaArray = (body) => { } }; -const handleError = (req, error, res, context) => { +/** + * Handle error logging and response + * @param req + * @param error + * @param res + * @param context + * @param logger + */ +const handleError = (req, error, res, context, logger) => { logger.log("sms-inbound-error", "ERROR", "api", null, { msid: req.body.SmsMessageSid, text: req.body.Body, image: !!req.body.MediaUrl0, - image_path: generateMediaArray(req.body), + image_path: generateMediaArray(req.body, logger), messagingServiceSid: req.body.MessagingServiceSid, context, error @@ -187,3 +335,7 @@ const handleError = (req, error, res, context) => { res.status(500).json({ error: error.message || "Internal Server Error" }); }; + +module.exports = { + receive +}; diff --git a/server/sms/send.js b/server/sms/send.js index eb8cb6e5f..fdd2c81ae 100644 --- a/server/sms/send.js +++ b/server/sms/send.js @@ -1,19 +1,20 @@ -const path = require("path"); -require("dotenv").config({ - path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) -}); - const twilio = require("twilio"); const { phone } = require("phone"); -const queries = require("../graphql-client/queries"); -const logger = require("../utils/logger"); +const { INSERT_MESSAGE } = require("../graphql-client/queries"); const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY); const gqlClient = require("../graphql-client/graphql-client").client; -exports.send = async (req, res) => { +/** + * Send an outbound SMS message + * @param req + * @param res + * @returns {Promise} + */ +const send = async (req, res) => { const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body; const { ioRedis, + logger, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } } = req; @@ -25,8 +26,8 @@ exports.send = async (req, res) => { conversationid, isoutbound: true, userid: req.user.email, - image: req.body.selectedMedia.length > 0, - image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] + image: selectedMedia.length > 0, + image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] }); if (!to || !messagingServiceSid || (!body && selectedMedia.length === 0) || !conversationid) { @@ -38,8 +39,8 @@ exports.send = async (req, res) => { conversationid, isoutbound: true, userid: req.user.email, - image: req.body.selectedMedia.length > 0, - image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] + image: selectedMedia.length > 0, + image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] }); res.status(400).json({ success: false, message: "Missing required parameter(s)." }); return; @@ -59,12 +60,15 @@ exports.send = async (req, res) => { conversationid, isoutbound: true, userid: req.user.email, - image: req.body.selectedMedia.length > 0, - image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] + image: selectedMedia.length > 0, + image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] }; try { - const gqlResponse = await gqlClient.request(queries.INSERT_MESSAGE, { msg: newMessage, conversationid }); + const gqlResponse = await gqlClient.request(INSERT_MESSAGE, { + msg: newMessage, + conversationid + }); logger.log("sms-outbound-success", "DEBUG", req.user.email, null, { msid: message.sid, @@ -111,3 +115,7 @@ exports.send = async (req, res) => { res.status(500).json({ success: false, message: "Failed to send message through Twilio." }); } }; + +module.exports = { + send +}; diff --git a/server/sms/status.js b/server/sms/status.js index 0e29bbf8f..3de841bc4 100644 --- a/server/sms/status.js +++ b/server/sms/status.js @@ -1,14 +1,21 @@ -const path = require("path"); -require("dotenv").config({ - path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) -}); - const client = require("../graphql-client/graphql-client").client; -const queries = require("../graphql-client/queries"); +const { + UPDATE_MESSAGE_STATUS, + MARK_MESSAGES_AS_READ, + INSERT_PHONE_NUMBER_OPT_OUT, + FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID +} = require("../graphql-client/queries"); const logger = require("../utils/logger"); +const { phone } = require("phone"); -exports.status = async (req, res) => { - const { SmsSid, SmsStatus } = req.body; +/** + * Handle the status of an SMS message + * @param req + * @param res + * @returns {Promise<*>} + */ +const status = async (req, res) => { + const { SmsSid, SmsStatus, ErrorCode, To, MessagingServiceSid } = req.body; const { ioRedis, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } @@ -20,18 +27,76 @@ exports.status = async (req, res) => { return res.status(200).json({ message: "Status 'queued' disregarded." }); } + // Handle ErrorCode 21610 (Attempt to send to unsubscribed recipient) first + if (ErrorCode === "21610" && To && MessagingServiceSid) { + try { + // Step 1: Find the bodyshop by MessagingServiceSid + const bodyshopResponse = await client.request(FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, { + mssid: MessagingServiceSid, + phone: phone(To).phoneNumber // Pass the normalized phone number as required + }); + + const bodyshop = bodyshopResponse.bodyshops[0]; + if (!bodyshop) { + logger.log("sms-opt-out-error", "ERROR", "api", null, { + msid: SmsSid, + messagingServiceSid: MessagingServiceSid, + to: To, + error: "No matching bodyshop found" + }); + } else { + // Step 2: Insert into phone_number_opt_out table + const now = new Date().toISOString(); + const optOutInput = { + bodyshopid: bodyshop.id, + phone_number: phone(To).phoneNumber.replace(/^\+1/, ""), // Normalize phone number (remove +1 for CA numbers) + created_at: now, + updated_at: now + }; + + const optOutResponse = await client.request(INSERT_PHONE_NUMBER_OPT_OUT, { + optOutInput: [optOutInput] + }); + + logger.log("sms-opt-out-success", "INFO", null, null, { + msid: SmsSid, + bodyshopid: bodyshop.id, + phone_number: optOutInput.phone_number, + affected_rows: optOutResponse.insert_phone_number_opt_out.affected_rows + }); + + // Store bodyshopid for potential use in WebSocket notification + const broadcastRoom = getBodyshopRoom(bodyshop.id); + ioRedis.to(broadcastRoom).emit("phone-number-opted-out", { + bodyshopid: bodyshop.id, + phone_number: optOutInput.phone_number + // Note: conversationId is not included yet; will be set after message lookup + }); + } + } catch (error) { + logger.log("sms-opt-out-error", "ERROR", "api", null, { + msid: SmsSid, + messagingServiceSid: MessagingServiceSid, + to: To, + error: error.message, + stack: error.stack + }); + // Continue processing to update message status + } + } + // Update message status in the database - const response = await client.request(queries.UPDATE_MESSAGE_STATUS, { + const response = await client.request(UPDATE_MESSAGE_STATUS, { msid: SmsSid, fields: { status: SmsStatus } }); - const message = response.update_messages.returning[0]; + const message = response.update_messages?.returning?.[0]; if (message) { logger.log("sms-status-update", "DEBUG", "api", null, { msid: SmsSid, - fields: { status: SmsStatus } + status: SmsStatus }); // Emit WebSocket event to notify the change in message status @@ -46,26 +111,32 @@ exports.status = async (req, res) => { type: "status-changed" }); } else { - logger.log("sms-status-update-warning", "WARN", "api", null, { + logger.log("sms-status-update-warning", "WARN", null, null, { msid: SmsSid, - fields: { status: SmsStatus }, - warning: "No message returned from the database update." + status: SmsStatus, + warning: "No message found in database for update" }); } res.sendStatus(200); - } catch (error) { + } catch (err) { logger.log("sms-status-update-error", "ERROR", "api", null, { msid: SmsSid, - fields: { status: SmsStatus }, - stack: error.stack, - message: error.message + status: SmsStatus, + error: err.message, + stack: err.stack }); res.status(500).json({ error: "Failed to update message status." }); } }; -exports.markConversationRead = async (req, res) => { +/** + * Mark a conversation as read + * @param req + * @param res + * @returns {Promise<*>} + */ +const markConversationRead = async (req, res) => { const { ioRedis, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } @@ -80,7 +151,7 @@ exports.markConversationRead = async (req, res) => { } try { - const response = await client.request(queries.MARK_MESSAGES_AS_READ, { + const response = await client.request(MARK_MESSAGES_AS_READ, { conversationId }); @@ -104,3 +175,8 @@ exports.markConversationRead = async (req, res) => { res.status(500).json({ error: "Failed to mark conversation as read." }); } }; + +module.exports = { + status, + markConversationRead +}; diff --git a/server/sso/canny.js b/server/sso/canny.js new file mode 100644 index 000000000..776844465 --- /dev/null +++ b/server/sso/canny.js @@ -0,0 +1,24 @@ +const logger = require("../utils/logger"); +const jwt = require("jsonwebtoken"); + +const cannySsoHandler = async (req, res) => { + try { + const userData = { + //avatarURL: user.avatarURL, // optional, but preferred + email: req.user.email, + id: req.user.uid, + name: req.user.displayName || req.user.email + }; + return res.status(200).send(jwt.sign(userData, process.env.CANNY_PRIVATE_KEY, { algorithm: "HS256" })); + } catch (error) { + logger.log("sso-canny-error", "error", req?.user?.email, null, { + message: error.message, + stack: error.stack + }); + res.status(500).json({ error: error.message }); + } +}; + +module.exports = { + cannySsoHandler +}; diff --git a/server/utils/ioHelpers.js b/server/utils/ioHelpers.js index 584d45ce7..f26440a50 100644 --- a/server/utils/ioHelpers.js +++ b/server/utils/ioHelpers.js @@ -1,3 +1,11 @@ +/** + * @module ioHelpers + * @param app + * @param api + * @param io + * @param logger + * @returns {{getBodyshopRoom: (function(*): string), getBodyshopConversationRoom: (function({bodyshopId: *, conversationId: *}): string)}} + */ const applyIOHelpers = ({ app, api, io, logger }) => { // Global Bodyshop Room const getBodyshopRoom = (bodyshopId) => `bodyshop-broadcast-room:${bodyshopId}`; diff --git a/server/utils/logger.js b/server/utils/logger.js index 9a27105fd..ccb2e905e 100644 --- a/server/utils/logger.js +++ b/server/utils/logger.js @@ -12,6 +12,9 @@ const { uploadFileToS3 } = require("./s3"); const { v4 } = require("uuid"); const { InstanceRegion } = require("./instanceMgr"); const getHostNameOrIP = require("./getHostNameOrIP"); +const client = require("../graphql-client/graphql-client").client; +const queries = require("../graphql-client/queries"); + const LOG_LEVELS = { error: { level: 0, name: "error" }, @@ -99,13 +102,11 @@ const createLogger = () => { const labelColor = "\x1b[33m"; // Yellow const separatorColor = "\x1b[35m|\x1b[0m"; // Magenta for separators - return `${timestampColor} [${hostnameColor}] [${level}]: ${message} ${ - user ? `${separatorColor} ${labelColor}user:\x1b[0m ${JSON.stringify(user)}` : "" - } ${record ? `${separatorColor} ${labelColor}record:\x1b[0m ${JSON.stringify(record)}` : ""}${ - meta + return `${timestampColor} [${hostnameColor}] [${level}]: ${message} ${user ? `${separatorColor} ${labelColor}user:\x1b[0m ${JSON.stringify(user)}` : "" + } ${record ? `${separatorColor} ${labelColor}record:\x1b[0m ${JSON.stringify(record)}` : ""}${meta ? `\n${separatorColor} ${labelColor}meta:\x1b[0m ${JSON.stringify(meta, null, 2)} ${separatorColor}` : "" - }`; + }`; }) ) }) @@ -194,9 +195,45 @@ const createLogger = () => { winstonLogger.log(logEntry); }; + const LogIntegrationCall = async ({ platform, method, name, jobid, paymentid, billid, status, bodyshopid, email }) => { + try { + //Insert the record. + await client.request(queries.INSERT_INTEGRATION_LOG, { + log: { + platform, + method, + name, + jobid, + paymentid, + billid, + status: status?.toString() ?? "0", + bodyshopid, + email + } + }); + + } catch (error) { + console.trace("Stack", error?.stack); + log("integration-log-error", "ERROR", email, null, { + message: error?.message, + stack: error?.stack, + platform, + method, + name, + jobid, + paymentid, + billid, + status, + bodyshopid, + email + }); + } + }; + return { log, - logger: winstonLogger + logger: winstonLogger, + LogIntegrationCall }; } catch (e) { console.error("Error setting up enhanced Logger, defaulting to console.: " + e?.message || ""); diff --git a/server/utils/s3.js b/server/utils/s3.js index 8b9251e03..2ba1f0d47 100644 --- a/server/utils/s3.js +++ b/server/utils/s3.js @@ -9,6 +9,7 @@ const { const { defaultProvider } = require("@aws-sdk/credential-provider-node"); const { InstanceRegion } = require("./instanceMgr"); const { isString, isEmpty } = require("lodash"); +const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); const createS3Client = () => { const S3Options = { @@ -95,6 +96,17 @@ const createS3Client = () => { throw error; } }; + + const getPresignedUrl = async ({ bucketName, key }) => { + const command = new PutObjectCommand({ + Bucket: bucketName, + Key: key, + StorageClass: "INTELLIGENT_TIERING" + }); + const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 360 }); + return presignedUrl; + } + return { uploadFileToS3, downloadFileFromS3, @@ -102,8 +114,12 @@ const createS3Client = () => { deleteFileFromS3, copyFileInS3, fileExistsInS3, + getPresignedUrl, ...s3Client }; }; + + + module.exports = createS3Client(); diff --git a/server/web-sockets/redisSocketEvents.js b/server/web-sockets/redisSocketEvents.js index e52fb5943..6710c2bed 100644 --- a/server/web-sockets/redisSocketEvents.js +++ b/server/web-sockets/redisSocketEvents.js @@ -38,6 +38,7 @@ const redisSocketEvents = ({ try { const user = await admin.auth().verifyIdToken(token); socket.user = user; + socket.bodyshopId = bodyshopId; await addUserSocketMapping(user.email, socket.id, bodyshopId); next(); } catch (error) { @@ -67,12 +68,8 @@ const redisSocketEvents = ({ return; } socket.user = user; + socket.bodyshopId = bodyshopId; await refreshUserSocketTTL(user.email, bodyshopId); - createLogEvent( - socket, - "debug", - `Token updated successfully for socket ID: ${socket.id} (bodyshop: ${bodyshopId})` - ); socket.emit("token-updated", { success: true }); } catch (error) { if (error.code === "auth/id-token-expired") { @@ -94,7 +91,6 @@ const redisSocketEvents = ({ try { const room = getBodyshopRoom(bodyshopUUID); socket.join(room); - // createLogEvent(socket, "debug", `Client joined bodyshop room: ${room}`); } catch (error) { createLogEvent(socket, "error", `Error joining room: ${error}`); } @@ -104,7 +100,6 @@ const redisSocketEvents = ({ try { const room = getBodyshopRoom(bodyshopUUID); socket.leave(room); - createLogEvent(socket, "debug", `Client left bodyshop room: ${room}`); } catch (error) { createLogEvent(socket, "error", `Error joining room: ${error}`); } @@ -114,8 +109,6 @@ const redisSocketEvents = ({ try { const room = getBodyshopRoom(bodyshopUUID); io.to(room).emit("bodyshop-message", message); - // We do not need this as these can be debugged live - // createLogEvent(socket, "debug", `Broadcast message to bodyshop ${room}`); } catch (error) { createLogEvent(socket, "error", `Error getting room: ${error}`); } @@ -201,7 +194,6 @@ const redisSocketEvents = ({ const registerSyncEvents = (socket) => { socket.on("sync-notification-read", async ({ email, bodyshopId, notificationId }) => { try { - const userEmail = socket.user.email; const socketMapping = await getUserSocketMappingByBodyshop(email, bodyshopId); const timestamp = new Date().toISOString(); @@ -212,11 +204,6 @@ const redisSocketEvents = ({ io.to(socketId).emit("sync-notification-read", { notificationId, timestamp }); } }); - createLogEvent( - socket, - "debug", - `Synced notification ${notificationId} read for ${userEmail} in bodyshop ${bodyshopId}` - ); } } catch (error) { createLogEvent(socket, "error", `Error syncing notification read: ${error.message}`); @@ -235,7 +222,6 @@ const redisSocketEvents = ({ io.to(socketId).emit("sync-all-notifications-read", { timestamp }); } }); - createLogEvent(socket, "debug", `Synced all notifications read for ${email} in bodyshop ${bodyshopId}`); } } catch (error) { createLogEvent(socket, "error", `Error syncing all notifications read: ${error.message}`); @@ -301,12 +287,34 @@ const redisSocketEvents = ({ }); }; + // Task Events + const registerTaskEvents = (socket) => { + socket.on("task-created", (payload) => { + if (!payload) return; + const room = getBodyshopRoom(socket.bodyshopId); + io.to(room).emit("bodyshop-message", { type: "task-created", payload }); + }); + + socket.on("task-updated", (payload) => { + if (!payload) return; + const room = getBodyshopRoom(socket.bodyshopId); + io.to(room).emit("bodyshop-message", { type: "task-updated", payload }); + }); + + socket.on("task-deleted", (payload) => { + if (!payload || !payload.id) return; + const room = getBodyshopRoom(socket.bodyshopId); + io.to(room).emit("bodyshop-message", { type: "task-deleted", payload }); + }); + }; + // Call Handlers registerRoomAndBroadcastEvents(socket); registerUpdateEvents(socket); registerMessagingEvents(socket); registerDisconnectEvents(socket); registerSyncEvents(socket); + registerTaskEvents(socket); registerFortellisEvents(socket); };