From 1384616d666e156a215f7478025feb528344fc25 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 19 Feb 2025 12:50:01 -0500 Subject: [PATCH] feature/IO-3096-GlobalNotifications - Cleanup and Package bumps --- client/package-lock.json | 176 +++++++++--------- client/package.json | 22 +-- package-lock.json | 16 +- package.json | 4 +- server/notifications/eventParser.js | 58 ++++-- .../notifications/notificationEmailQueue.js | 23 --- server/notifications/queues/appQueue.js | 74 ++++++-- server/notifications/queues/emailQueue.js | 76 +++++--- server/notifications/scenarioBuilders.js | 104 ++++++++++- server/notifications/scenarioParser.js | 47 +++-- server/notifications/stringHelpers.js | 13 ++ 11 files changed, 415 insertions(+), 198 deletions(-) delete mode 100644 server/notifications/notificationEmailQueue.js diff --git a/client/package-lock.json b/client/package-lock.json index c01d27cb4..bd947dc12 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9,18 +9,18 @@ "version": "0.2.1", "hasInstallScript": true, "dependencies": { - "@ant-design/pro-layout": "^7.22.2", - "@apollo/client": "^3.12.11", + "@ant-design/pro-layout": "^7.22.3", + "@apollo/client": "^3.13.1", "@emotion/is-prop-valid": "^1.3.1", "@fingerprintjs/fingerprintjs": "^4.6.0", "@jsreport/browser-client": "^3.1.0", "@reduxjs/toolkit": "^2.5.0", - "@sentry/cli": "^2.40.0", + "@sentry/cli": "^2.42.1", "@sentry/react": "^7.114.0", "@splitsoftware/splitio-react": "^1.13.0", "@tanem/react-nprogress": "^5.0.53", "@vitejs/plugin-react": "^4.3.4", - "antd": "^5.24.0", + "antd": "^5.24.1", "apollo-link-logger": "^2.0.1", "apollo-link-sentry": "^3.3.0", "autosize": "^6.0.1", @@ -36,9 +36,9 @@ "firebase": "^10.13.2", "graphql": "^16.10.0", "i18next": "^23.15.1", - "i18next-browser-languagedetector": "^8.0.2", + "i18next-browser-languagedetector": "^8.0.3", "immutability-helper": "^3.1.1", - "libphonenumber-js": "^1.11.18", + "libphonenumber-js": "^1.11.20", "logrocket": "^8.1.2", "markerjs2": "^2.32.3", "memoize-one": "^6.0.0", @@ -56,7 +56,7 @@ "react-grid-gallery": "^1.0.1", "react-grid-layout": "1.3.4", "react-i18next": "^14.1.3", - "react-icons": "^5.4.0", + "react-icons": "^5.5.0", "react-image-lightbox": "^5.1.4", "react-markdown": "^9.0.3", "react-number-format": "^5.4.3", @@ -74,12 +74,12 @@ "redux-saga": "^1.3.0", "redux-state-sync": "^3.1.4", "reselect": "^5.1.1", - "sass": "^1.84.0", + "sass": "^1.85.0", "socket.io-client": "^4.8.1", "styled-components": "^6.1.15", "subscriptions-transport-ws": "^0.11.0", "use-memo-one": "^1.1.3", - "userpilot": "^1.3.6", + "userpilot": "^1.3.7", "vite-plugin-ejs": "^1.7.0", "web-vitals": "^3.5.2" }, @@ -105,10 +105,10 @@ "globals": "^15.15.0", "memfs": "^4.17.0", "os-browserify": "^0.3.0", - "react-error-overlay": "6.0.11", + "react-error-overlay": "^6.1.0", "redux-logger": "^3.0.6", "source-map-explorer": "^2.5.3", - "vite": "^6.1.0", + "vite": "^6.1.1", "vite-plugin-babel": "^1.3.0", "vite-plugin-eslint": "^1.8.1", "vite-plugin-node-polyfills": "^0.23.0", @@ -218,15 +218,15 @@ "license": "MIT" }, "node_modules/@ant-design/pro-layout": { - "version": "7.22.2", - "resolved": "https://registry.npmjs.org/@ant-design/pro-layout/-/pro-layout-7.22.2.tgz", - "integrity": "sha512-RlXqN+EVnF1Sup84O0IjS/vMMgwFnbBZwvR+GVnmZg/+cIa4/BDTXyhbb1KRwUqzn1ctDzj7JfbWOWqmGMw6yA==", + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@ant-design/pro-layout/-/pro-layout-7.22.3.tgz", + "integrity": "sha512-di/EOMDuoMDRjBweqesYyCxEYr2LCmO82y6A4bSwmmJ6ehxN7HGC73Wx4RuBkzDR7kHLTOXt7WxI6875ENT8mg==", "license": "MIT", "dependencies": { "@ant-design/cssinjs": "^1.21.1", "@ant-design/icons": "^5.0.0", "@ant-design/pro-provider": "2.15.3", - "@ant-design/pro-utils": "2.16.3", + "@ant-design/pro-utils": "2.16.4", "@babel/runtime": "^7.18.0", "@umijs/route-utils": "^4.0.0", "@umijs/use-params": "^1.0.9", @@ -265,9 +265,9 @@ } }, "node_modules/@ant-design/pro-utils": { - "version": "2.16.3", - "resolved": "https://registry.npmjs.org/@ant-design/pro-utils/-/pro-utils-2.16.3.tgz", - "integrity": "sha512-uNjKh51v/SUlCJbWfhg2lRQB/TB0MyNMCQkFZ8mZBQ2rk3Ew47Sly6VssVVWMjIWBLE+g9fOgPg0C1IVeilIXA==", + "version": "2.16.4", + "resolved": "https://registry.npmjs.org/@ant-design/pro-utils/-/pro-utils-2.16.4.tgz", + "integrity": "sha512-PFxqF0fsUsLj8ORvJSuMgVv9NDHwAxZaglzPN/u3jZX7rWYcrHD04EMJEXooZaSyT6Q4+j7SqXDx6oBsdb9zNw==", "license": "MIT", "dependencies": { "@ant-design/icons": "^5.0.0", @@ -322,9 +322,9 @@ } }, "node_modules/@apollo/client": { - "version": "3.12.11", - "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.12.11.tgz", - "integrity": "sha512-1RppV9U3E6Uusl/33yGkZa+rXpkGU5iCstcYltwWjdTjoA/YBD2Yyu0aHy8J4uKfIExUgnMW1HJWn4A0E0rRsw==", + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.13.1.tgz", + "integrity": "sha512-HaAt62h3jNUXpJ1v5HNgUiCzPP1c5zc2Q/FeTb2cTk/v09YlhoqKKHQFJI7St50VCJ5q8JVIc03I5bRcBrQxsg==", "license": "MIT", "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", @@ -5510,9 +5510,9 @@ } }, "node_modules/@sentry/cli": { - "version": "2.41.1", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.41.1.tgz", - "integrity": "sha512-0GVmDiTV7R1492wkVY4bGcfC0fSmRmQjuxaaPI8CIV9B2VP9pBVCUizi1mevXaaE4I3fM60LI+XYrKFEneuVog==", + "version": "2.42.1", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.42.1.tgz", + "integrity": "sha512-3fonGZoGwlze/iGYDdCJXpG5skXc6j/yYom+k6TqVvJJqSct1RgLJHjCw1P0IxHsR8pNz9f1H85OdLXKxrc6sw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -5529,19 +5529,19 @@ "node": ">= 10" }, "optionalDependencies": { - "@sentry/cli-darwin": "2.41.1", - "@sentry/cli-linux-arm": "2.41.1", - "@sentry/cli-linux-arm64": "2.41.1", - "@sentry/cli-linux-i686": "2.41.1", - "@sentry/cli-linux-x64": "2.41.1", - "@sentry/cli-win32-i686": "2.41.1", - "@sentry/cli-win32-x64": "2.41.1" + "@sentry/cli-darwin": "2.42.1", + "@sentry/cli-linux-arm": "2.42.1", + "@sentry/cli-linux-arm64": "2.42.1", + "@sentry/cli-linux-i686": "2.42.1", + "@sentry/cli-linux-x64": "2.42.1", + "@sentry/cli-win32-i686": "2.42.1", + "@sentry/cli-win32-x64": "2.42.1" } }, "node_modules/@sentry/cli-darwin": { - "version": "2.41.1", - "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.41.1.tgz", - "integrity": "sha512-7pS3pu/SuhE6jOn3wptstAg6B5nUP878O6s+2svT7b5fKNfYUi/6NPK6dAveh2Ca0rwVq40TO4YFJabWMgTpdQ==", + "version": "2.42.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.42.1.tgz", + "integrity": "sha512-WZFsrzSWtsRK24SiTa+Xod+4Hjlw7xaggmM4lbuo0lISO1EQj+K29jyGX+Ku0qflO1qp1z32bSP/RlWx/1rBjg==", "license": "BSD-3-Clause", "optional": true, "os": [ @@ -5552,9 +5552,9 @@ } }, "node_modules/@sentry/cli-linux-arm": { - "version": "2.41.1", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.41.1.tgz", - "integrity": "sha512-wNUvquD6qjOCczvuBGf9OiD29nuQ6yf8zzfyPJa5Bdx1QXuteKsKb6HBrMwuIR3liyuu0duzHd+H/+p1n541Hg==", + "version": "2.42.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.42.1.tgz", + "integrity": "sha512-3xR2B9v8e7NjB6U9+oMu2puR3xOv/Axd7qNuUrZxQnNZYtgtnAqIDgSmFTWHOOoged1+AZXe+xDWLN0Y11Q03Q==", "cpu": [ "arm" ], @@ -5569,9 +5569,9 @@ } }, "node_modules/@sentry/cli-linux-arm64": { - "version": "2.41.1", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.41.1.tgz", - "integrity": "sha512-EzYCEnnENBnS5kpNW+2dBcrPZn1MVfywh2joGVQZTpmgDL5YFJ59VOd+K0XuEwqgFI8BSNI14KXZ75s4DD1/Vw==", + "version": "2.42.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.42.1.tgz", + "integrity": "sha512-8A43bLvDIzquCXblHNadaRm109ANw1Q9VRXg5qLYv7DrPkUm2oQP+oRnuNUgOJ3W/8QQSvANpG9pPko+mJs4xw==", "cpu": [ "arm64" ], @@ -5586,9 +5586,9 @@ } }, "node_modules/@sentry/cli-linux-i686": { - "version": "2.41.1", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.41.1.tgz", - "integrity": "sha512-urpQCWrdYnSAsZY3udttuMV88wTJzKZL10xsrp7sjD/Hd+O6qSLVLkxebIlxts70jMLLFHYrQ2bkRg5kKuX6Fg==", + "version": "2.42.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.42.1.tgz", + "integrity": "sha512-YBz6prKqh1i0gzTg3Rus8ALQWmAk5Acap2U2dGuVYgTt7Bbu6SJbxNC9d8j3RUGu7ylupofUEMqKd391mTHf7g==", "cpu": [ "x86", "ia32" @@ -5604,9 +5604,9 @@ } }, "node_modules/@sentry/cli-linux-x64": { - "version": "2.41.1", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.41.1.tgz", - "integrity": "sha512-ZqpYwHXAaK4MMEFlyaLYr6mJTmpy9qP6n30jGhLTW7kHKS3s6GPLCSlNmIfeClrInEt0963fM633ZRnXa04VPw==", + "version": "2.42.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.42.1.tgz", + "integrity": "sha512-Rvc6Jy3kLZrcyO7Ysy1gj0iQi0nGVUN79VqC3OO9JDV44aDtKBDYuBkeFKE3gd1SL8EvPetKH85en2u2wdWxYg==", "cpu": [ "x64" ], @@ -5621,9 +5621,9 @@ } }, "node_modules/@sentry/cli-win32-i686": { - "version": "2.41.1", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.41.1.tgz", - "integrity": "sha512-AuRimCeVsx99DIOr9cwdYBHk39tlmAuPDdy2r16iNzY0InXs4xOys4gGzM7N4vlFQvFkzuc778Su0HkfasgprA==", + "version": "2.42.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.42.1.tgz", + "integrity": "sha512-FC8FE6dk+G83PCO09Ux/9NJNouF5yXKhpzLV5BZkqQye39hV9GDrFTu+VWTnwI1P77fnaJkPEEKRkjwNiPGjLA==", "cpu": [ "x86", "ia32" @@ -5638,9 +5638,9 @@ } }, "node_modules/@sentry/cli-win32-x64": { - "version": "2.41.1", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.41.1.tgz", - "integrity": "sha512-6JcPvXGye61+wPp0xdzfc2YLE/Dcud8JdaK8VxLM3b/8+Em7E+UyliDu3uF8+YGUqizY5JYTd3fs17DC8DZhLw==", + "version": "2.42.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.42.1.tgz", + "integrity": "sha512-1595wD7JQSu5J9pA4m/B3WrjjIXltSV9VzuErehvanBvfusQ/YgBcvsNzgIf8aJsgSAYGbpR3Zqu81pjohdjgA==", "cpu": [ "x64" ], @@ -6759,9 +6759,9 @@ } }, "node_modules/antd": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/antd/-/antd-5.24.0.tgz", - "integrity": "sha512-05PZBIf6ijLHAQskBTW3nwS2t7tQmyLA6Xq8vK2Sk5tsgCsH/UE1cNCDYnKFGRJ7cKYuWJ565JDo9LejbiO42A==", + "version": "5.24.1", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.24.1.tgz", + "integrity": "sha512-RGwpXpSr2RtoUnrpJl3V6ZaTExwSXkFVxV24VUowwC04n6oA1sGyJrofQOKNqD623sVxL5UJBmf0a+BFBImP3Q==", "license": "MIT", "dependencies": { "@ant-design/colors": "^7.2.0", @@ -6794,7 +6794,7 @@ "rc-motion": "^2.9.5", "rc-notification": "~5.6.3", "rc-pagination": "~5.1.0", - "rc-picker": "~4.11.0", + "rc-picker": "~4.11.1", "rc-progress": "~4.0.0", "rc-rate": "~2.13.1", "rc-resize-observer": "^1.4.3", @@ -6803,7 +6803,7 @@ "rc-slider": "~11.1.8", "rc-steps": "~6.0.1", "rc-switch": "~4.1.0", - "rc-table": "~7.50.2", + "rc-table": "~7.50.3", "rc-tabs": "~15.5.1", "rc-textarea": "~1.9.0", "rc-tooltip": "~6.4.0", @@ -11525,9 +11525,9 @@ } }, "node_modules/i18next-browser-languagedetector": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.2.tgz", - "integrity": "sha512-shBvPmnIyZeD2VU5jVGIOWP7u9qNG3Lj7mpaiPFpbJ3LVfHZJvVzKR4v1Cb91wAOFpNw442N+LGPzHOHsten2g==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.3.tgz", + "integrity": "sha512-beOOLArattPBc2YZG5IXGJytdYFgUR7cS8Wd6HT4IczIoWKgmTspOQ2yasaGklelVo5seLPmnEKvLHR+E/MdWQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.2" @@ -12723,9 +12723,9 @@ } }, "node_modules/libphonenumber-js": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.19.tgz", - "integrity": "sha512-bW/Yp/9dod6fmyR+XqSUL1N5JE7QRxQ3KrBIbYS1FTv32e5i3SEtQVX+71CYNv8maWNSOgnlCoNp9X78f/cKiA==", + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.20.tgz", + "integrity": "sha512-/ipwAMvtSZRdiQBHqW1qxqeYiBMzncOQLVA+62MWYr7N4m7Q2jqpJ0WgT7zlOEOpyLRSqrMXidbJpC0J77AaKA==", "license": "MIT" }, "node_modules/lie": { @@ -15485,9 +15485,9 @@ } }, "node_modules/rc-table": { - "version": "7.50.2", - "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.50.2.tgz", - "integrity": "sha512-+nJbzxzstBriLb5sr9U7Vjs7+4dO8cWlouQbMwBVYghk2vr508bBdkHJeP/z9HVjAIKmAgMQKxmtbgDd3gc5wA==", + "version": "7.50.3", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.50.3.tgz", + "integrity": "sha512-Z4/zNCzjv7f/XzPRecb+vJU0DJKdsYt4YRkDzNl4G05m7JmxrKGYC2KqN1Ew6jw2zJq7cxVv3z39qyZOHMuf7A==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.1", @@ -15761,9 +15761,9 @@ } }, "node_modules/react-error-overlay": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", - "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.1.0.tgz", + "integrity": "sha512-SN/U6Ytxf1QGkw/9ve5Y+NxBbZM6Ht95tuXNMKs8EJyFa/Vy/+Co3stop3KBHARfn/giv+Lj1uUnTfOJ3moFEQ==", "dev": true, "license": "MIT" }, @@ -15816,9 +15816,9 @@ } }, "node_modules/react-icons": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", - "integrity": "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", "license": "MIT", "peerDependencies": { "react": "*" @@ -16798,9 +16798,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.84.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.84.0.tgz", - "integrity": "sha512-XDAbhEPJRxi7H0SxrnOpiXFQoUJHwkR2u3Zc4el+fK/Tt5Hpzw5kkQ59qVDfvdaUq6gCrEZIbySFBM2T9DNKHg==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz", + "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", "license": "MIT", "dependencies": { "chokidar": "^4.0.0", @@ -17861,9 +17861,9 @@ } }, "node_modules/swr": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.0.tgz", - "integrity": "sha512-NyZ76wA4yElZWBHzSgEJc28a0u6QZvhb6w0azeL2k7+Q1gAzVK+IqQYXhVOC/mzi+HZIozrZvBVeSeOZNR2bqA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.2.tgz", + "integrity": "sha512-RosxFpiabojs75IwQ316DGoDRmOqtiAj0tg8wCcbEu4CiLZBs/a9QNtHV7TUfDXmmlgqij/NqzKq/eLelyv9xA==", "license": "MIT", "dependencies": { "dequal": "^2.0.3", @@ -18849,9 +18849,9 @@ } }, "node_modules/userpilot": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/userpilot/-/userpilot-1.3.6.tgz", - "integrity": "sha512-NK/4sQTnWrpER164PkWzLdLjUc2766B4yeLdLiFDoRfyLNAc3SecLWszZH6oPlv67B+XcYzqtmzEalE86bkljw==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/userpilot/-/userpilot-1.3.7.tgz", + "integrity": "sha512-F6RiNcaYaHuWZyVLzyYBZQ4YAQzAgN4tWYyRkI0ZST/HSYwoVI6DGenNHEEqPoy28rZngVehXMyKKQ/1aRL7vQ==", "license": "MIT", "dependencies": { "@ndhoule/includes": "^2.0.1", @@ -18962,14 +18962,14 @@ } }, "node_modules/vite": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", - "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.1.tgz", + "integrity": "sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.24.2", - "postcss": "^8.5.1", + "postcss": "^8.5.2", "rollup": "^4.30.1" }, "bin": { @@ -19194,9 +19194,9 @@ } }, "node_modules/vite/node_modules/postcss": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", - "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "dev": true, "funding": [ { diff --git a/client/package.json b/client/package.json index 1a092df97..d8c20b987 100644 --- a/client/package.json +++ b/client/package.json @@ -8,18 +8,18 @@ "private": true, "proxy": "http://localhost:4000", "dependencies": { - "@ant-design/pro-layout": "^7.22.2", - "@apollo/client": "^3.12.11", + "@ant-design/pro-layout": "^7.22.3", + "@apollo/client": "^3.13.1", "@emotion/is-prop-valid": "^1.3.1", "@fingerprintjs/fingerprintjs": "^4.6.0", "@jsreport/browser-client": "^3.1.0", "@reduxjs/toolkit": "^2.5.0", - "@sentry/cli": "^2.40.0", + "@sentry/cli": "^2.42.1", "@sentry/react": "^7.114.0", "@splitsoftware/splitio-react": "^1.13.0", "@tanem/react-nprogress": "^5.0.53", "@vitejs/plugin-react": "^4.3.4", - "antd": "^5.24.0", + "antd": "^5.24.1", "apollo-link-logger": "^2.0.1", "apollo-link-sentry": "^3.3.0", "autosize": "^6.0.1", @@ -35,9 +35,9 @@ "firebase": "^10.13.2", "graphql": "^16.10.0", "i18next": "^23.15.1", - "i18next-browser-languagedetector": "^8.0.2", + "i18next-browser-languagedetector": "^8.0.3", "immutability-helper": "^3.1.1", - "libphonenumber-js": "^1.11.18", + "libphonenumber-js": "^1.11.20", "logrocket": "^8.1.2", "markerjs2": "^2.32.3", "memoize-one": "^6.0.0", @@ -55,7 +55,7 @@ "react-grid-gallery": "^1.0.1", "react-grid-layout": "1.3.4", "react-i18next": "^14.1.3", - "react-icons": "^5.4.0", + "react-icons": "^5.5.0", "react-image-lightbox": "^5.1.4", "react-markdown": "^9.0.3", "react-number-format": "^5.4.3", @@ -73,12 +73,12 @@ "redux-saga": "^1.3.0", "redux-state-sync": "^3.1.4", "reselect": "^5.1.1", - "sass": "^1.84.0", + "sass": "^1.85.0", "socket.io-client": "^4.8.1", "styled-components": "^6.1.15", "subscriptions-transport-ws": "^0.11.0", "use-memo-one": "^1.1.3", - "userpilot": "^1.3.6", + "userpilot": "^1.3.7", "vite-plugin-ejs": "^1.7.0", "web-vitals": "^3.5.2" }, @@ -141,10 +141,10 @@ "globals": "^15.15.0", "memfs": "^4.17.0", "os-browserify": "^0.3.0", - "react-error-overlay": "6.0.11", + "react-error-overlay": "^6.1.0", "redux-logger": "^3.0.6", "source-map-explorer": "^2.5.3", - "vite": "^6.1.0", + "vite": "^6.1.1", "vite-plugin-babel": "^1.3.0", "vite-plugin-eslint": "^1.8.1", "vite-plugin-node-polyfills": "^0.23.0", diff --git a/package-lock.json b/package-lock.json index 171d6b3bb..c355f60cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,8 @@ "better-queue": "^3.8.12", "bluebird": "^3.7.2", "body-parser": "^1.20.3", - "bullmq": "^5.40.4", - "chart.js": "^4.4.6", + "bullmq": "^5.41.3", + "chart.js": "^4.4.8", "cloudinary": "^2.5.1", "compression": "^1.8.0", "cookie-parser": "^1.4.7", @@ -4093,9 +4093,9 @@ } }, "node_modules/bullmq": { - "version": "5.40.4", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.40.4.tgz", - "integrity": "sha512-MaIOhc31ZbVi9HbY0VAalsXoywelzEPNr6dojoKSMCXDnEVTQ27LkT5LA0Mlpr7ZunMLfpH94SLYrWNsPMsQrg==", + "version": "5.41.3", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.41.3.tgz", + "integrity": "sha512-tWTeuO/BHDg6gKVnQJMjO42zkhsGss6s4bMdgJU24JVBT53yUvDjaO9H0L/BHKAtsMi4xlxkrDuMNSYWeHlekA==", "license": "MIT", "dependencies": { "cron-parser": "^4.9.0", @@ -4235,9 +4235,9 @@ } }, "node_modules/chart.js": { - "version": "4.4.7", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.7.tgz", - "integrity": "sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw==", + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.8.tgz", + "integrity": "sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA==", "license": "MIT", "dependencies": { "@kurkle/color": "^0.3.0" diff --git a/package.json b/package.json index a5ba882a8..11873366e 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,8 @@ "better-queue": "^3.8.12", "bluebird": "^3.7.2", "body-parser": "^1.20.3", - "bullmq": "^5.40.4", - "chart.js": "^4.4.6", + "bullmq": "^5.41.3", + "chart.js": "^4.4.8", "cloudinary": "^2.5.1", "compression": "^1.8.0", "cookie-parser": "^1.4.7", diff --git a/server/notifications/eventParser.js b/server/notifications/eventParser.js index 4cd0d30fd..6d9985697 100644 --- a/server/notifications/eventParser.js +++ b/server/notifications/eventParser.js @@ -1,65 +1,87 @@ /** * Parses an event by comparing old and new data to determine which fields have changed. + * + * This function analyzes the differences between previous (`oldData`) and current (`newData`) + * data states to identify changed fields. It determines if the event is a new entry or an update + * and optionally extracts a `jobId` based on a specified field. The result includes details + * about changed fields, the event type, and associated metadata. + * + * @param {Object} options - Configuration options for parsing the event. + * @param {Object} [options.oldData] - The previous state of the data (undefined for new entries). + * @param {Object} options.newData - The current state of the data. + * @param {string} options.trigger - The type of event trigger (e.g., 'INSERT', 'UPDATE'). + * @param {string} options.table - The name of the table associated with the event. + * @param {string} [options.jobIdField] - The field name used to extract the jobId (optional). + * @returns {Object} An object containing the parsed event details: + * - {Array} changedFieldNames - List of field names that have changed. + * - {Object} changedFields - Map of changed fields with their old and new values. + * - {boolean} isNew - True if the event is a new entry (no oldData provided). + * - {Object} data - The current data state (`newData`). + * - {string} trigger - The event trigger type. + * - {string} table - The table name. + * - {string|null} jobId - The extracted jobId or null if not applicable. */ const eventParser = async ({ oldData, newData, trigger, table, jobIdField }) => { - const isNew = !oldData; + const isNew = !oldData; // True if no old data exists, indicating a new entry let changedFields = {}; let changedFieldNames = []; if (isNew) { - // If there's no old data, every field in newData is considered new + // For new entries, all fields in newData are considered "changed" (from undefined to their value) changedFields = Object.fromEntries( Object.entries(newData).map(([key, value]) => [key, { old: undefined, new: value }]) ); - changedFieldNames = Object.keys(newData); + changedFieldNames = Object.keys(newData); // All keys are new } else { - // Compare oldData with newData for changes + // Compare oldData and newData to detect updates for (const key in newData) { if (Object.prototype.hasOwnProperty.call(newData, key)) { - // Check if the key exists in oldData and if values differ + // Check if the field is new or its value has changed if ( - !Object.prototype.hasOwnProperty.call(oldData, key) || - JSON.stringify(oldData[key]) !== JSON.stringify(newData[key]) + !Object.prototype.hasOwnProperty.call(oldData, key) || // Field didn’t exist before + JSON.stringify(oldData[key]) !== JSON.stringify(newData[key]) // Values differ (deep comparison) ) { changedFields[key] = { - old: oldData[key], // Could be undefined if key didn’t exist in oldData + old: oldData[key], // Undefined if field wasn’t in oldData new: newData[key] }; changedFieldNames.push(key); } } } - // Check for fields that were removed + // Identify fields removed in newData (present in oldData but absent in newData) for (const key in oldData) { if (Object.prototype.hasOwnProperty.call(oldData, key) && !Object.prototype.hasOwnProperty.call(newData, key)) { changedFields[key] = { old: oldData[key], - new: null // Indicate field was removed + new: null // Mark as removed }; changedFieldNames.push(key); } } } - // Extract jobId based on jobIdField + // Extract jobId if jobIdField is provided let jobId = null; if (jobIdField) { let keyName = jobIdField; const prefix = "req.body.event.new."; + // Strip prefix if present to isolate the actual field name if (keyName.startsWith(prefix)) { keyName = keyName.slice(prefix.length); } + // Look for jobId in newData first, then fallback to oldData if necessary jobId = newData[keyName] || (oldData && oldData[keyName]) || null; } return { - changedFieldNames, - changedFields, - isNew, - data: newData, - trigger, - table, - jobId + changedFieldNames, // Array of fields that changed + changedFields, // Object with old/new values for changed fields + isNew, // Boolean indicating if this is a new entry + data: newData, // Current data state + trigger, // Event trigger (e.g., 'INSERT', 'UPDATE') + table, // Associated table name + jobId // Extracted jobId or null }; }; diff --git a/server/notifications/notificationEmailQueue.js b/server/notifications/notificationEmailQueue.js deleted file mode 100644 index 209422566..000000000 --- a/server/notifications/notificationEmailQueue.js +++ /dev/null @@ -1,23 +0,0 @@ -const path = require("path"); -require("dotenv").config({ - path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) -}); -const Queue = require("better-queue"); - -const logger = require("../utils/logger"); - -const notificationsEmailQueue = () => - new Queue( - (taskIds, cb) => { - logger.log("Processing Notification Emails: ", "silly", null, null); - cb(null); - }, - { - batchSize: 50, - batchDelay: 5000, - // The lower this is, the more likely we are to hit the rate limit. - batchDelayTimeout: 1000 - } - ); - -module.exports = { notificationsEmailQueue }; diff --git a/server/notifications/queues/appQueue.js b/server/notifications/queues/appQueue.js index b165e4e71..6cb209263 100644 --- a/server/notifications/queues/appQueue.js +++ b/server/notifications/queues/appQueue.js @@ -3,22 +3,36 @@ const { Queue, Worker } = require("bullmq"); let addQueue; let consolidateQueue; +/** + * Initializes the notification queues and workers for adding and consolidating notifications. + * + * @param {Object} options - Configuration options for queue initialization. + * @param {Object} options.pubClient - Redis client instance for queue communication. + * @param {Object} options.logger - Logger instance for logging events and debugging. + * @param {Object} options.redisHelpers - Utility functions for Redis operations. + * @param {Object} options.ioRedis - Socket.io Redis adapter for real-time event emission. + * @returns {Queue} The initialized `addQueue` instance for dispatching notifications. + */ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { + // Only initialize if queues don't already exist if (!addQueue || !consolidateQueue) { logger.logger.info("Initializing Notifications Queues"); + // Create queue for adding notifications addQueue = new Queue("notificationsAdd", { connection: pubClient, - prefix: "{BULLMQ}", - defaultJobOptions: { removeOnComplete: true, removeOnFail: true } + prefix: "{BULLMQ}", // Namespace prefix for BullMQ in Redis + defaultJobOptions: { removeOnComplete: true, removeOnFail: true } // Cleanup jobs after success/failure }); + // Create queue for consolidating notifications consolidateQueue = new Queue("notificationsConsolidate", { connection: pubClient, prefix: "{BULLMQ}", defaultJobOptions: { removeOnComplete: true, removeOnFail: true } }); + // Worker to process jobs from the addQueue const addWorker = new Worker( "notificationsAdd", async (job) => { @@ -28,27 +42,32 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { const redisKeyPrefix = `app:notifications:${jobId}`; const notification = { key, variables, timestamp: Date.now() }; + // Store notification for each recipient in Redis for (const recipient of recipients) { const { user } = recipient; const userKey = `${redisKeyPrefix}:${user}`; const existingNotifications = await pubClient.get(userKey); const notifications = existingNotifications ? JSON.parse(existingNotifications) : []; notifications.push(notification); + // Set with 40-second expiration to avoid stale data await pubClient.set(userKey, JSON.stringify(notifications), "EX", 40); logger.logger.debug(`Stored notification for ${user} under ${userKey}: ${JSON.stringify(notifications)}`); } const consolidateKey = `app:consolidate:${jobId}`; + // setnx ensures only one consolidation job is scheduled (atomic operation) const flagSet = await pubClient.setnx(consolidateKey, "pending"); logger.logger.debug(`Consolidation flag set for jobId ${jobId}: ${flagSet}`); if (flagSet) { + // Schedule consolidation job to run after a 5-second delay await consolidateQueue.add( "consolidate-notifications", { jobId, recipients }, { jobId: `consolidate:${jobId}`, delay: 5000 } ); logger.logger.info(`Scheduled consolidation for jobId ${jobId}`); + // Set expiration on flag to clean up after 5 minutes await pubClient.expire(consolidateKey, 300); } else { logger.logger.debug(`Consolidation already scheduled for jobId ${jobId}`); @@ -57,10 +76,11 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { { connection: pubClient, prefix: "{BULLMQ}", - concurrency: 5 + concurrency: 5 // Process up to 5 jobs concurrently } ); + // Worker to process jobs from the consolidateQueue const consolidateWorker = new Worker( "notificationsConsolidate", async (job) => { @@ -69,15 +89,18 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { const redisKeyPrefix = `app:notifications:${jobId}`; const lockKey = `lock:consolidate:${jobId}`; + // Acquire a lock to prevent concurrent consolidation (NX = set if not exists) const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", 10); logger.logger.debug(`Lock acquisition for jobId ${jobId}: ${lockAcquired}`); if (lockAcquired) { try { const allNotifications = {}; + // Get unique user IDs to avoid duplicate processing const uniqueUsers = [...new Set(recipients.map((r) => r.user))]; logger.logger.debug(`Unique users for jobId ${jobId}: ${uniqueUsers}`); + // Retrieve and structure notifications by user and bodyShopId for (const user of uniqueUsers) { const userKey = `${redisKeyPrefix}:${user}`; const notifications = await pubClient.get(userKey); @@ -90,7 +113,7 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { allNotifications[user] = allNotifications[user] || {}; allNotifications[user][bodyShopId] = parsedNotifications; } - await pubClient.del(userKey); + await pubClient.del(userKey); // Clean up after retrieval logger.logger.debug(`Deleted Redis key ${userKey}`); } else { logger.logger.warn(`No notifications found for ${user} under ${userKey}`); @@ -99,6 +122,7 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { logger.logger.debug(`Consolidated notifications: ${JSON.stringify(allNotifications)}`); + // Emit notifications to users via Socket.io for (const [user, bodyShopData] of Object.entries(allNotifications)) { const userMapping = await redisHelpers.getUserSocketMapping(user); logger.logger.debug(`User socket mapping for ${user}: ${JSON.stringify(userMapping)}`); @@ -107,7 +131,11 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { if (userMapping && userMapping[bodyShopId]?.socketIds) { userMapping[bodyShopId].socketIds.forEach((socketId) => { logger.logger.debug( - `Emitting to socket ${socketId}: ${JSON.stringify({ jobId, bodyShopId, notifications })}` + `Emitting to socket ${socketId}: ${JSON.stringify({ + jobId, + bodyShopId, + notifications + })}` ); ioRedis.to(socketId).emit("notification", { jobId, @@ -124,12 +152,13 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { } } + // Clean up consolidation flag after processing await pubClient.del(`app:consolidate:${jobId}`); } catch (err) { logger.logger.error(`Consolidation error for jobId ${jobId}: ${err.message}`, { error: err }); - throw err; // Re-throw to trigger failed event + throw err; // Re-throw to trigger BullMQ's failed event } finally { - await pubClient.del(lockKey); + await pubClient.del(lockKey); // Release lock regardless of success/failure } } else { logger.logger.info(`Skipped consolidation for jobId ${jobId} - lock held by another worker`); @@ -138,42 +167,63 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { { connection: pubClient, prefix: "{BULLMQ}", - concurrency: 1, - limiter: { max: 1, duration: 5000 } + concurrency: 1, // Single concurrency to avoid race conditions + limiter: { max: 1, duration: 5000 } // Rate limit: 1 job every 5 seconds } ); + // Log worker completion events addWorker.on("completed", (job) => logger.logger.info(`Add job ${job.id} completed`)); consolidateWorker.on("completed", (job) => logger.logger.info(`Consolidate job ${job.id} completed`)); + + // Log worker failure events with error details addWorker.on("failed", (job, err) => logger.logger.error(`Add job ${job.id} failed: ${err.message}`, { error: err }) ); + consolidateWorker.on("failed", (job, err) => logger.logger.error(`Consolidate job ${job.id} failed: ${err.message}`, { error: err }) ); + // Graceful shutdown handler for workers const shutdown = async () => { logger.logger.info("Closing app queue workers..."); await Promise.all([addWorker.close(), consolidateWorker.close()]); logger.logger.info("App queue workers closed"); }; - process.on("SIGTERM", shutdown); - process.on("SIGINT", shutdown); + + process.on("SIGTERM", shutdown); // Handle termination signal + process.on("SIGINT", shutdown); // Handle interrupt signal (e.g., Ctrl+C) } - return addQueue; // Return the add queue for dispatching + return addQueue; // Return queue for external use }; +/** + * Retrieves the initialized `addQueue` instance. + * + * @returns {Queue} The `addQueue` instance for adding notifications. + * @throws {Error} If `addQueue` is not initialized (i.e., `loadAppQueue` wasn’t called). + */ const getQueue = () => { if (!addQueue) throw new Error("Add queue not initialized. Ensure loadAppQueue is called during bootstrap."); return addQueue; }; +/** + * Dispatches notifications to the `addQueue` for processing. + * + * @param {Object} options - Options for dispatching notifications. + * @param {Array} options.appsToDispatch - Array of notification objects to dispatch. + * @param {Object} options.logger - Logger instance for logging dispatch events. + * @returns {Promise} Resolves when all notifications are added to the queue. + */ const dispatchAppsToQueue = async ({ appsToDispatch, logger }) => { const appQueue = getQueue(); for (const app of appsToDispatch) { const { jobId, bodyShopId, key, variables, recipients } = app; + // Unique jobId with timestamp to avoid duplicates await appQueue.add( "add-notification", { jobId, bodyShopId, key, variables, recipients }, diff --git a/server/notifications/queues/emailQueue.js b/server/notifications/queues/emailQueue.js index b93975891..6ca97d3c8 100644 --- a/server/notifications/queues/emailQueue.js +++ b/server/notifications/queues/emailQueue.js @@ -4,30 +4,44 @@ const { sendTaskEmail } = require("../../email/sendemail"); let emailQueue; let worker; -const loadEmailQueue = async ({ pubClient, logger, redisHelpers }) => { +// Consolidate the same way the App Queue Does. + +/** + * Initializes the email queue and worker for sending notifications via email. + * + * @param {Object} options - Configuration options for queue initialization. + * @param {Object} options.pubClient - Redis client instance for queue communication. + * @param {Object} options.logger - Logger instance for logging events and debugging. + * @returns {Queue} The initialized `emailQueue` instance for dispatching emails. + */ +const loadEmailQueue = async ({ pubClient, logger }) => { + // Only initialize if queue doesn't already exist if (!emailQueue) { logger.logger.info("Initializing Notifications Email Queue"); + + // Create queue for email notifications emailQueue = new Queue("notificationsEmails", { connection: pubClient, - prefix: "{BULLMQ}", + prefix: "{BULLMQ}", // Namespace prefix for BullMQ in Redis defaultJobOptions: { - attempts: 3, + attempts: 3, // Retry failed jobs up to 3 times backoff: { - type: "exponential", - delay: 1000 + type: "exponential", // Exponential backoff strategy + delay: 1000 // Initial delay of 1 second } } }); - // Initialize the worker during queue setup + // Worker to process jobs from the emailQueue worker = new Worker( "notificationsEmails", async (job) => { - const { subject, body, recipients } = job.data; - logger.logger.debug(`Processing email job ${job.id} for ${recipients.length} recipients`); + const { subject, body, recipient } = job.data; + logger.logger.debug(`Processing email job ${job.id} for recipient ${recipient}`); + // Send email to a single recipient await sendTaskEmail({ - to: recipients.map((r) => r.user), + to: recipient, // Single email address subject, type: "text", text: body @@ -38,9 +52,9 @@ const loadEmailQueue = async ({ pubClient, logger, redisHelpers }) => { { connection: pubClient, prefix: "{BULLMQ}", - concurrency: 2, // Reduced for multi-node setup; adjust based on load + concurrency: 2, // Process up to 2 jobs concurrently limiter: { - max: 10, // Max 10 jobs per minute per worker + max: 10, // Maximum of 10 jobs per minute duration: 60 * 1000 // 1 minute } } @@ -59,7 +73,7 @@ const loadEmailQueue = async ({ pubClient, logger, redisHelpers }) => { logger.logger.error("Worker error:", { error: err }); }); - // Graceful shutdown handling + // Graceful shutdown handler for the worker const shutdown = async () => { if (worker) { logger.logger.info("Closing email queue worker..."); @@ -68,13 +82,19 @@ const loadEmailQueue = async ({ pubClient, logger, redisHelpers }) => { } }; - process.on("SIGTERM", shutdown); - process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); // Handle termination signal + process.on("SIGINT", shutdown); // Handle interrupt signal (e.g., Ctrl+C) } - return emailQueue; + return emailQueue; // Return queue for external use }; +/** + * Retrieves the initialized `emailQueue` instance. + * + * @returns {Queue} The `emailQueue` instance for sending emails. + * @throws {Error} If `emailQueue` is not initialized (i.e., `loadEmailQueue` wasn’t called). + */ const getQueue = () => { if (!emailQueue) { throw new Error("Email queue not initialized. Ensure loadEmailQueue is called during bootstrap."); @@ -82,17 +102,31 @@ const getQueue = () => { return emailQueue; }; +/** + * Dispatches emails to the `emailQueue` for processing, creating one job per recipient. + * + * @param {Object} options - Options for dispatching emails. + * @param {Array} options.emailsToDispatch - Array of email objects to dispatch. + * @param {Object} options.logger - Logger instance for logging dispatch events. + * @returns {Promise} Resolves when all email jobs are added to the queue. + */ const dispatchEmailsToQueue = async ({ emailsToDispatch, logger }) => { const emailQueue = getQueue(); for (const email of emailsToDispatch) { const { subject, body, recipients } = email; - await emailQueue.add("send-email", { - subject, - body, - recipients - }); // Job options moved to defaultJobOptions in Queue - logger.logger.debug(`Added email to queue: ${subject} for ${recipients.length} recipients`); + // Create an array of jobs, one per recipient + const jobs = recipients.map((recipient) => ({ + name: "send-email", + data: { + subject, + body, + recipient: recipient.user // Extract the email address from recipient object + } + })); + // Add all jobs for this email in one operation + await emailQueue.addBulk(jobs); + logger.logger.debug(`Added ${jobs.length} email jobs to queue for subject: ${subject}`); } }; diff --git a/server/notifications/scenarioBuilders.js b/server/notifications/scenarioBuilders.js index 90aaf40ff..189c51928 100644 --- a/server/notifications/scenarioBuilders.js +++ b/server/notifications/scenarioBuilders.js @@ -1,14 +1,29 @@ const { getJobAssignmentType } = require("./stringHelpers"); +/** + * Populates the recipients for app, email, and FCM notifications based on scenario watchers. + * + * @param {Object} data - The data object containing scenarioWatchers and bodyShopId. + * @param {Object} result - The result object to populate with recipients for app, email, and FCM notifications. + */ const populateWatchers = (data, result) => { data.scenarioWatchers.forEach((recipients) => { const { user, app, fcm, email } = recipients; + // Add user to app recipients with bodyShopId if app notification is enabled if (app === true) result.app.recipients.push({ user, bodyShopId: data.bodyShopId }); + // Add user to FCM recipients if FCM notification is enabled if (fcm === true) result.fcm.recipients.push(user); + // Add user to email recipients if email notification is enabled if (email === true) result.email.recipients.push({ user }); }); }; +/** + * Builds notification data for changes to alternate transport. + * + * @param {Object} data - The data object containing job details and alternate transport changes. + * @returns {Object} Notification data structured for app, email, and FCM channels. + */ const alternateTransportChangedBuilder = (data) => { const result = { app: { @@ -33,6 +48,12 @@ const alternateTransportChangedBuilder = (data) => { return result; }; +/** + * Builds notification data for bill posted events. + * + * @param {Object} data - The data object containing job and billing details. + * @returns {Object} Notification data structured for app, email, and FCM channels. + */ const billPostedHandler = (data) => { const result = { app: { @@ -56,6 +77,12 @@ const billPostedHandler = (data) => { return result; }; +/** + * Builds notification data for changes to critical parts status. + * + * @param {Object} data - The data object containing job details and critical parts status changes. + * @returns {Object} Notification data structured for app, email, and FCM channels. + */ const criticalPartsStatusChangedBuilder = (data) => { const result = { app: { @@ -80,7 +107,14 @@ const criticalPartsStatusChangedBuilder = (data) => { return result; }; +/** + * Builds notification data for completed intake or delivery checklists. + * + * @param {Object} data - The data object containing job details and checklist changes. + * @returns {Object} Notification data structured for app, email, and FCM channels. + */ const intakeDeliveryChecklistCompletedBuilder = (data) => { + // Determine checklist type based on which field was changed const checklistType = data.changedFields.intakechecklist ? "intake" : "delivery"; const result = { app: { @@ -105,6 +139,12 @@ const intakeDeliveryChecklistCompletedBuilder = (data) => { return result; }; +/** + * Builds notification data for job assignment events. + * + * @param {Object} data - The data object containing job details and scenario fields. + * @returns {Object} Notification data structured for app, email, and FCM channels. + */ const jobAssignedToMeBuilder = (data) => { const result = { app: { @@ -128,6 +168,12 @@ const jobAssignedToMeBuilder = (data) => { return result; }; +/** + * Builds notification data for jobs added to production. + * + * @param {Object} data - The data object containing job details. + * @returns {Object} Notification data structured for app, email, and FCM channels. + */ const jobsAddedToProductionBuilder = (data) => { const result = { app: { @@ -149,7 +195,12 @@ const jobsAddedToProductionBuilder = (data) => { return result; }; -// Verified +/** + * Builds notification data for job status changes. + * + * @param {Object} data - The data object containing job details and status changes. + * @returns {Object} Notification data structured for app, email, and FCM channels. + */ const jobStatusChangeBuilder = (data) => { const result = { app: { @@ -164,7 +215,7 @@ const jobStatusChangeBuilder = (data) => { }, email: { subject: `The status of ${data?.jobRoNumber} (${data.bodyShopName}) has changed from ${data.changedFields.status.old} to ${data.data.status}`, - body: `...`, + body: `...`, // Placeholder indicating email body may need further customization recipients: [] }, fcm: { recipients: [] } @@ -174,6 +225,12 @@ const jobStatusChangeBuilder = (data) => { return result; }; +/** + * Builds notification data for new media added or reassigned events. + * + * @param {Object} data - The data object containing job details. + * @returns {Object} Notification data structured for app, email, and FCM channels. + */ const newMediaAddedReassignedBuilder = (data) => { const result = { app: { @@ -195,7 +252,12 @@ const newMediaAddedReassignedBuilder = (data) => { return result; }; -// Verified +/** + * Builds notification data for new notes added to a job. + * + * @param {Object} data - The data object containing job details and note text. + * @returns {Object} Notification data structured for app, email, and FCM channels. + */ const newNoteAddedBuilder = (data) => { const result = { app: { @@ -219,6 +281,12 @@ const newNoteAddedBuilder = (data) => { return result; }; +/** + * Builds notification data for new time tickets posted. + * + * @param {Object} data - The data object containing job details. + * @returns {Object} Notification data structured for app, email, and FCM channels. + */ const newTimeTicketPostedBuilder = (data) => { const result = { app: { @@ -240,6 +308,12 @@ const newTimeTicketPostedBuilder = (data) => { return result; }; +/** + * Builds notification data for parts marked as back-ordered. + * + * @param {Object} data - The data object containing job details and parts status changes. + * @returns {Object} Notification data structured for app, email, and FCM channels. + */ const partMarkedBackOrderedBuilder = (data) => { const result = { app: { @@ -264,6 +338,12 @@ const partMarkedBackOrderedBuilder = (data) => { return result; }; +/** + * Builds notification data for payment collection events. + * + * @param {Object} data - The data object containing job and payment details. + * @returns {Object} Notification data structured for app, email, and FCM channels. + */ const paymentCollectedCompletedBuilder = (data) => { const result = { app: { @@ -287,6 +367,12 @@ const paymentCollectedCompletedBuilder = (data) => { return result; }; +/** + * Builds notification data for changes to scheduled dates. + * + * @param {Object} data - The data object containing job details and scheduling changes. + * @returns {Object} Notification data structured for app, email, and FCM channels. + */ const scheduledDatesChangedBuilder = (data) => { const result = { app: { @@ -315,6 +401,12 @@ const scheduledDatesChangedBuilder = (data) => { return result; }; +/** + * Builds notification data for supplement imported events. + * + * @param {Object} data - The data object containing job and supplement details. + * @returns {Object} Notification data structured for app, email, and FCM channels. + */ const supplementImportedBuilder = (data) => { const result = { app: { @@ -338,6 +430,12 @@ const supplementImportedBuilder = (data) => { return result; }; +/** + * Builds notification data for tasks updated or created. + * + * @param {Object} data - The data object containing job details and task event type. + * @returns {Object} Notification data structured for app, email, and FCM channels. + */ const tasksUpdatedCreatedBuilder = (data) => { const result = { app: { diff --git a/server/notifications/scenarioParser.js b/server/notifications/scenarioParser.js index ae111fe9c..a7c3a7815 100644 --- a/server/notifications/scenarioParser.js +++ b/server/notifications/scenarioParser.js @@ -2,6 +2,9 @@ * @module scenarioParser * @description * This module exports a function that parses an event and triggers notification scenarios based on the event data. + * It integrates with event parsing utilities, GraphQL queries, and notification queues to manage the dispatching + * of notifications via email and app channels. The function processes event data, identifies relevant scenarios, + * queries user notification preferences, and dispatches notifications accordingly. */ const eventParser = require("./eventParser"); @@ -9,23 +12,28 @@ const { client: gqlClient } = require("../graphql-client/graphql-client"); const queries = require("../graphql-client/queries"); const { isEmpty, isFunction } = require("lodash"); const { getMatchingScenarios } = require("./scenarioMapperr"); -const consoleDir = require("../utils/consoleDir"); const { dispatchEmailsToQueue } = require("./queues/emailQueue"); const { dispatchAppsToQueue } = require("./queues/appQueue"); /** * Parses an event and determines matching scenarios for notifications. * Queries job watchers and notification settings before triggering scenario builders. + * + * @param {Object} req - The request object containing event data, trigger, table, and logger. + * @param {string} jobIdField - The field name used to extract the job ID from the event data. + * @returns {Promise} Resolves when the parsing and notification dispatching process is complete. + * @throws {Error} If required request fields (event data, trigger, or table) or body shop data are missing. */ const scenarioParser = async (req, jobIdField) => { const { event, trigger, table } = req.body; const { logger } = req; + // Validate that required fields are present in the request body if (!event?.data || !trigger || !table) { throw new Error("Missing required request fields: event data, trigger, or table."); } - // Step 1: Parse event data to extract necessary details. + // Step 1: Parse the event data to extract details like job ID and changed fields const eventData = await eventParser({ newData: event.data.new, oldData: event.data.old, @@ -34,11 +42,12 @@ const scenarioParser = async (req, jobIdField) => { jobIdField }); - // Step 2: Query job watchers for the given job ID. + // Step 2: Query job watchers associated with the job ID using GraphQL const watcherData = await gqlClient.request(queries.GET_JOB_WATCHERS, { jobid: eventData.jobId }); + // Transform watcher data into a simplified format with email and employee details const jobWatchers = watcherData?.job_watchers_aggregate?.nodes?.map((watcher) => ({ email: watcher.user_email, firstName: watcher?.user?.employee?.first_name, @@ -46,21 +55,23 @@ const scenarioParser = async (req, jobIdField) => { employeeId: watcher?.user?.employee?.id })); + // Exit early if no job watchers are found for this job if (isEmpty(jobWatchers)) { return; } - // Step 3: Retrieve body shop information from the job. + // Step 3: Extract body shop information from the job data const bodyShopId = watcherData?.job?.bodyshop?.id; const bodyShopName = watcherData?.job?.bodyshop?.shopname; const jobRoNumber = watcherData?.job?.ro_number; const jobClaimNumber = watcherData?.job?.clm_no; + // Validate that body shop data exists, as it’s required for notifications if (!bodyShopId || !bodyShopName) { throw new Error("No bodyshop data found for this job."); } - // Step 4: Determine matching scenarios based on event data. + // Step 4: Identify scenarios that match the event data and job context const matchingScenarios = getMatchingScenarios({ ...eventData, jobWatchers, @@ -68,10 +79,12 @@ const scenarioParser = async (req, jobIdField) => { bodyShopName }); + // Exit early if no matching scenarios are identified if (isEmpty(matchingScenarios)) { return; } + // Combine event data with additional context for scenario processing const finalScenarioData = { ...eventData, jobWatchers, @@ -80,22 +93,24 @@ const scenarioParser = async (req, jobIdField) => { matchingScenarios }; - // Step 5: Query notification settings for job watchers. + // Step 5: Query notification settings for the job watchers const associationsData = await gqlClient.request(queries.GET_NOTIFICATION_ASSOCIATIONS, { emails: jobWatchers.map((x) => x.email), shopid: bodyShopId }); + // Exit early if no notification associations are found if (isEmpty(associationsData?.associations)) { return; } - // Step 6: Filter scenario watchers based on enabled notification methods. + // Step 6: Filter scenario watchers based on their enabled notification methods finalScenarioData.matchingScenarios = finalScenarioData.matchingScenarios.map((scenario) => ({ ...scenario, scenarioWatchers: associationsData.associations .filter((assoc) => { const settings = assoc.notification_settings && assoc.notification_settings[scenario.key]; + // Include only watchers with at least one enabled notification method (app, email, or FCM) return settings && (settings.app || settings.email || settings.fcm); }) .map((assoc) => { @@ -103,6 +118,7 @@ const scenarioParser = async (req, jobIdField) => { const watcherEmail = assoc.user || assoc.useremail; const matchingWatcher = jobWatchers.find((watcher) => watcher.email === watcherEmail); + // Build watcher object with notification preferences and personal details return { user: watcherEmail, email: settings.email, @@ -115,21 +131,23 @@ const scenarioParser = async (req, jobIdField) => { }) })); + // Exit early if no scenarios have eligible watchers after filtering if (isEmpty(finalScenarioData?.matchingScenarios)) { return; } - // Step 7: Trigger scenario builders for matching scenarios with eligible watchers. + // Step 7: Build and collect scenarios to dispatch notifications for const scenariosToDispatch = []; for (const scenario of finalScenarioData.matchingScenarios) { + // Skip if no watchers or no builder function is defined for the scenario if (isEmpty(scenario.scenarioWatchers) || !isFunction(scenario.builder)) { continue; } let eligibleWatchers = scenario.scenarioWatchers; - // Ensure watchers are only notified if they are assigned to the changed field. + // Filter watchers to only those assigned to changed fields, if specified if (scenario.matchToUserFields && scenario.matchToUserFields.length > 0) { eligibleWatchers = scenario.scenarioWatchers.filter((watcher) => scenario.matchToUserFields.some( @@ -138,14 +156,16 @@ const scenarioParser = async (req, jobIdField) => { ); } + // Skip if no watchers remain after filtering if (isEmpty(eligibleWatchers)) { continue; } - // Step 8: Filter scenario fields to only include changed fields. + // Step 8: Filter scenario fields to include only those that changed const filteredScenarioFields = scenario.fields?.filter((field) => eventData.changedFieldNames.includes(field)) || []; + // Use the scenario’s builder to construct the notification data scenariosToDispatch.push( scenario.builder({ trigger: finalScenarioData.trigger.name, @@ -167,30 +187,33 @@ const scenarioParser = async (req, jobIdField) => { ); } + // Exit early if no scenarios are ready to dispatch if (isEmpty(scenariosToDispatch)) { return; } - // Step 9: Dispatch Email Notifications to the Email Notification Queue + // Step 9: Dispatch email notifications to the email queue const emailsToDispatch = scenariosToDispatch.map((scenario) => scenario?.email); if (!isEmpty(emailsToDispatch)) { dispatchEmailsToQueue({ emailsToDispatch, logger }).catch((e) => + // Log any errors encountered during email dispatching logger.log("Something went wrong dispatching emails to the Email Notification Queue", "error", "queue", null, { message: e?.message }) ); } - // Step 10: Dispatch App Notifications to the App Notification Queue + // Step 10: Dispatch app notifications to the app queue const appsToDispatch = scenariosToDispatch.map((scenario) => scenario?.app); if (!isEmpty(appsToDispatch)) { dispatchAppsToQueue({ appsToDispatch, logger }).catch((e) => + // Log any errors encountered during app notification dispatching logger.log("Something went wrong dispatching apps to the App Notification Queue", "error", "queue", null, { message: e?.message }) diff --git a/server/notifications/stringHelpers.js b/server/notifications/stringHelpers.js index 82699e4a0..fe3a0a52c 100644 --- a/server/notifications/stringHelpers.js +++ b/server/notifications/stringHelpers.js @@ -1,3 +1,16 @@ +/** + * @module jobAssignmentHelper + * @description + * This module provides utility functions for handling job assignment types. + * Currently, it includes a function to map lowercase job assignment codes to their corresponding human-readable job types. + */ + +/** + * Maps a lowercase job assignment code to its corresponding human-readable job type. + * + * @param {string} data - The lowercase job assignment code (e.g., "employee_pre"). + * @returns {string} The human-readable job type (e.g., "Prep"). Returns an empty string if the code is unknown or if the input is null/undefined. + */ const getJobAssignmentType = (data) => { switch (data) { case "employee_pre":