Compare commits

...

97 Commits

Author SHA1 Message Date
Dave Richer
2c0eab9366 IO-3096-GlobalNotifications - Correct time zone from footer in notification email 2025-03-14 11:27:28 -04:00
Patrick Fic
b831d8ca8a IO-3096 Add indexes for notifications. 2025-03-13 15:27:20 -07:00
Dave Richer
69da6bccf7 IO-3096-GlobalNotifications - Adjust splits 2025-03-13 17:37:36 -04:00
Dave Richer
e3d7ebd7d8 IO-3096-GlobalNotifications - Verify status reporter is a function and exists prior to calling it in cleanup task 2025-03-13 14:59:58 -04:00
Dave Richer
5f0b63a192 IO-3096-GlobalNotifications - Add in a function to exclude extra logging from production 2025-03-13 13:56:30 -04:00
Dave Richer
7a5ac739ab Merge branch 'feature/IO-3170-HotFixForRedis' into feature/IO-3096-GlobalNotifications 2025-03-13 11:49:31 -04:00
Dave Richer
e2297be0af IO-3170-HotfixFoRedis 2025-03-13 11:47:21 -04:00
Dave Richer
73c4983342 Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2199)
IO-3166-Global-Notifications-Part-2: Remove unused event handler (hasura),
2025-03-13 15:31:28 +00:00
Dave Richer
166e1e4030 IO-3166-Global-Notifications-Part-2: Remove unused event handler (hasura), 2025-03-13 11:29:41 -04:00
Dave Richer
5fa7377121 Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2196)
IO-3166-Global-Notifications-Part-2: add additional key prefixes for dev v prod
2025-03-13 01:12:33 +00:00
Dave Richer
f21ba8e087 IO-3166-Global-Notifications-Part-2: add additional key prefixes for dev v prod 2025-03-12 21:10:42 -04:00
Dave Richer
d56d1f369c Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2193)
IO-3166-Global-Notifications-Part-2: Make sure BULLMQ prefixes do not collide
2025-03-13 00:01:56 +00:00
Dave Richer
360a1954f4 IO-3166-Global-Notifications-Part-2: Make sure BULLMQ prefixes do not collide 2025-03-12 20:00:53 -04:00
Dave Richer
6b047418cc Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2190)
Feature/IO-3166 Global Notifications Part 2
2025-03-12 16:06:34 +00:00
Dave Richer
87db292e5d IO-3166-Global-Notifications-Part-2: Fix typo in builder function name 2025-03-12 12:05:21 -04:00
Dave Richer
9ef8440e64 IO-3166-Global-Notifications-Part-2: Add Enabled key to scenario map (backend), filter out scenarios not enabled. 2025-03-12 11:46:09 -04:00
Dave Richer
8ae3b28cb6 IO-3166-Global-Notifications-Part-2: checkpoint, Modify additional strings as per Allan, Refactor builder down to prevent duplicate logic, comment out supplement imported. 2025-03-12 11:34:50 -04:00
Dave Richer
0d80854196 Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2186)
Feature/IO-3166 Global Notifications Part 2
2025-03-11 19:14:02 +00:00
Dave Richer
029fb58f48 IO-3166-Global-Notifications-Part-2: checkpoint 2025-03-11 14:47:54 -04:00
Dave Richer
85929b0bb1 IO-3166-Global-Notifications-Part-2: checkpoint 2025-03-11 14:09:35 -04:00
Dave Richer
dc234e4d72 IO-3166-Global-Notifications-Part-2: checkpoint 2025-03-11 13:57:05 -04:00
Dave Richer
212fc4a7cc Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2183)
Feature/IO-3166 Global Notifications Part 2
2025-03-11 17:18:10 +00:00
Dave Richer
8de7db60e6 IO-3166-Global-Notifications-Part-2: checkpoint 2025-03-11 13:16:47 -04:00
Dave Richer
d6df5af1a4 IO-3166-Global-Notifications-Part-2: checkpoint 2025-03-11 11:57:16 -04:00
Dave Richer
8d36ad3589 IO-3166-Global-Notifications-Part-2: checkpoint 2025-03-11 11:38:56 -04:00
Dave Richer
9061821347 IO-3166-Global-Notifications-Part-2: Fixed unread notifications not vanishing once marked as read in unread only 2025-03-11 11:00:42 -04:00
Dave Richer
1fad3968bb Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2179)
IO-3166-Global-Notifications-Part-2: getAwsClusterFix
2025-03-07 20:59:43 +00:00
Dave Richer
1d84dd1a83 IO-3166-Global-Notifications-Part-2: getAwsClusterFix 2025-03-07 15:58:52 -05:00
Dave Richer
a492909ad7 Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2176)
IO-3170-Enhanced-GetRedisEndpointsFromAWS - Fix to prevent breaking
2025-03-07 20:27:22 +00:00
Dave Richer
14a885b443 Merge branch 'hotfix/IO-3170-Enhanced-GetRedisEndpointsFromAWS' into feature/IO-3166-Global-Notifications-Part-2 2025-03-07 15:26:22 -05:00
Dave Richer
d5bd9d9b59 IO-3170-Enhanced-GetRedisEndpointsFromAWS - Fix to prevent breaking 2025-03-07 15:19:40 -05:00
Dave Richer
6e6cabbd63 Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2172)
IO-3166-Global-Notifications-Part-2 - Improved GetRedisNodesFromAWS
2025-03-07 20:11:02 +00:00
Dave Richer
480838b1dc IO-3166-Global-Notifications-Part-2 - Improved GetRedisNodesFromAWS 2025-03-07 15:10:06 -05:00
Dave Richer
ffadd31a5f Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2168)
IO-3166-Global-Notifications-Part-2 - Small styling change
2025-03-07 18:51:21 +00:00
Dave Richer
235527140c IO-3166-Global-Notifications-Part-2 - Small styling change 2025-03-07 13:50:40 -05:00
Dave Richer
ef22ba3d2c Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2165)
IO-3166-Global-Notifications-Part-2 - Checkpoint
2025-03-07 16:04:27 +00:00
Dave Richer
11ff8e91c7 IO-3166-Global-Notifications-Part-2 - Checkpoint 2025-03-07 10:58:01 -05:00
Dave Richer
71dd138f2f Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2162)
Feature/IO-3166 Global Notifications Part 2
2025-03-06 22:43:24 +00:00
Dave Richer
36f4cc8cb8 IO-3166-Global-Notifications-Part-2 - Checkpoint 2025-03-06 17:41:54 -05:00
Patrick Fic
d2944ff902 IO-3166 Update notification strings. 2025-03-06 14:38:00 -08:00
Dave Richer
3cbcbb92eb Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2159)
Feature/IO-3166 Global Notifications Part 2
2025-03-06 21:06:15 +00:00
Dave Richer
02e6c6007c IO-3166-Global-Notifications-Part-2 - Checkpoint 2025-03-06 16:05:42 -05:00
Dave Richer
2cee5f1944 IO-3166-Global-Notifications-Part-2 - Checkpoint 2025-03-06 16:02:01 -05:00
Dave Richer
ef695776cd Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2156)
Feature/IO-3166 Global Notifications Part 2
2025-03-06 18:39:46 +00:00
Dave Richer
53580fbc78 IO-3166-Global-Notifications-Part-2 - Checkpoint 2025-03-06 13:36:19 -05:00
Dave Richer
21335d4e8c IO-3166-Global-Notifications-Part-2 - Checkpoint - job watchers styling 2025-03-05 21:05:24 -05:00
Dave Richer
9b545d6c8c Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2153)
Feature/IO-3166 Global Notifications Part 2
2025-03-05 22:30:11 +00:00
Dave Richer
fbe674a2e5 IO-3166-Global-Notifications-Part-2 - Checkpoint 2025-03-05 17:29:24 -05:00
Dave Richer
2a65cb5025 IO-3166-Global-Notifications-Part-2 - Checkpoint 2025-03-05 17:28:32 -05:00
Dave Richer
b4a3960eac Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2150)
Feature/IO-3166 Global Notifications Part 2
2025-03-05 18:54:42 +00:00
Dave Richer
358503f9ef IO-3166-Global-Notifications-Part-2 - Checkpoint 2025-03-05 12:46:45 -05:00
Dave Richer
25a9e6cea1 IO-3166-Global-Notifications-Part-2 - Checkpoint 2025-03-05 12:18:01 -05:00
Dave Richer
e40e0bbb8f Merged release/2024-03-14 into feature/IO-3096-GlobalNotifications 2025-03-05 16:45:08 +00:00
Dave Richer
8fdd07827e Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2147)
Feature/IO-3166 Global Notifications Part 2
2025-03-05 16:44:40 +00:00
Dave Richer
059067bc61 IO-3166-Global-Notifications-Part-2 - Checkpoint 2025-03-05 11:43:05 -05:00
Dave Richer
f8ae6dc5af IO-3166-Global-Notifications-Part-2 - Checkpoint 2025-03-05 11:07:28 -05:00
Dave Richer
ac2bb42124 Merged in feature/IO-3096-GlobalNotifications (pull request #2145)
Feature/IO-3096 GlobalNotifications
2025-03-04 22:55:58 +00:00
Dave Richer
b149f70b6f Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2144)
Feature/IO-3166 Global Notifications Part 2
2025-03-04 22:55:26 +00:00
Dave Richer
ec8a413ed1 IO-3166-Global-Notifications-Part-2 - Checkpoint 2025-03-04 17:54:57 -05:00
Dave Richer
76ec755d07 IO-3166-Global-Notifications-Part-2 - Checkpoint 2025-03-04 17:50:58 -05:00
Dave Richer
07faa5eec2 IO-3166-Global-Notifications-Part-2 - Checkpoint 2025-03-04 17:07:31 -05:00
Dave Richer
7bbbf5934a Merged in feature/IO-3096-GlobalNotifications (pull request #2143)
Feature/IO-3096 GlobalNotifications
2025-03-04 16:57:30 +00:00
Dave Richer
fd7850b551 IO-3096-GlobalNotifications: Self Watcher env var was not handled correctly 2025-03-04 11:56:46 -05:00
Dave Richer
2b76f8a12d IO-3096-GlobalNotifications: Package Updates to match test-AIO 2025-03-04 11:40:19 -05:00
Dave Richer
aa073cfd68 IO-3096-GlobalNotifications: Fixed a small typo in emailQueue 2025-03-04 11:38:21 -05:00
Dave Richer
03863ce838 Merged in feature/IO-3096-GlobalNotifications (pull request #2141)
Feature/IO-3096 GlobalNotifications
2025-03-04 16:21:54 +00:00
Dave Richer
1b22697429 feature/IO-3096-GlobalNotifications - Code Review Part 5 2025-03-04 11:21:21 -05:00
Dave Richer
4fc3fbdcc0 Merged in release/2025-02-28 (pull request #2140)
IO-2561 Return Items Modal
2025-03-04 16:18:49 +00:00
Dave Richer
163978930f feature/IO-3096-GlobalNotifications - Code Review Part 4 2025-03-03 23:29:47 -05:00
Dave Richer
c75e27e018 feature/IO-3096-GlobalNotifications - Code Review Part 3 2025-03-03 23:20:01 -05:00
Dave Richer
555bedbb6c feature/IO-3096-GlobalNotifications - Code Review Part 2 2025-03-03 23:11:03 -05:00
Dave Richer
a57abec81b feature/IO-3096-GlobalNotifications - Code Review Part 1 2025-03-03 22:14:33 -05:00
Dave Richer
b9df4c2587 feature/IO-3096-GlobalNotifications - Logging / Merge release 2025-03-03 14:36:18 -05:00
Dave Richer
15686bdab8 Merge remote-tracking branch 'origin/release/2025-02-28' into feature/IO-3096-GlobalNotifications 2025-03-03 14:35:52 -05:00
Dave Richer
175e2097fa feature/IO-3096-GlobalNotifications - Logging 2025-03-03 14:00:38 -05:00
Dave Richer
359c4c75a1 feature/IO-3096-GlobalNotifications - typo 2025-03-03 13:43:28 -05:00
Dave Richer
86aa5bf5e7 feature/IO-3096-GlobalNotifications - Checkpoint - Additional String Cleanup, loading spinner 2025-03-03 12:07:19 -05:00
Dave Richer
35b92570e5 feature/IO-3096-GlobalNotifications - Checkpoint - Splits are now in place 2025-03-03 11:41:10 -05:00
Dave Richer
b5c03b8cf0 feature/IO-3096-GlobalNotifications - Checkpoint - add some missing keys (cleanup) 2025-03-03 11:00:55 -05:00
Dave Richer
3c45519457 feature/IO-3096-GlobalNotifications - Checkpoint - merge master 2025-03-03 10:57:27 -05:00
Patrick Fic
dc60b8d18e Merged in feature/IO-3162-sentry-improvements (pull request #2138)
Feature/IO-3162 sentry improvements
2025-02-28 23:44:58 +00:00
Patrick Fic
ea75ac49aa IO-3162 Resize test CI boxes. 2025-02-28 15:25:03 -08:00
Patrick Fic
f3c6c7f004 IO-3162 Sentry cleanup. 2025-02-28 15:18:42 -08:00
Patrick Fic
65fb73ae82 IO-3162 Add Prod/Test restriction on sentry init. 2025-02-28 15:14:56 -08:00
Patrick Fic
617e39eb17 IO-3162 Add CI paramters to aid in sourcemap generation. 2025-02-28 15:03:16 -08:00
Dave Richer
f4a3b75a86 feature/IO-3096-GlobalNotifications - Checkpoint - Header finalized, scenarioParser now uses ENV var for FILTER_SELF from watchers. 2025-02-28 17:33:46 -05:00
Patrick Fic
c0ffda27cf IO-3162 Additional logging improvements. 2025-02-28 14:21:36 -08:00
Dave Richer
f51fa08961 feature/IO-3096-GlobalNotifications - Checkpoint - Header finalized, scenarioParser now uses ENV var for FILTER_SELF from watchers. 2025-02-28 17:17:13 -05:00
Patrick Fic
ba63e8054f IO-3162 Sentry package upgrades and refactors. 2025-02-28 12:15:47 -08:00
Allan Carr
32813032e6 Merged in feature/IO-2561-Return-Items-Modal (pull request #2131)
Feature/IO-2561 Return Items Modal

Approved-by: Dave Richer
2025-02-28 17:36:37 +00:00
Dave Richer
a5904f55aa feature/IO-3096-GlobalNotifications - styling checkpoint 2025-02-28 12:14:50 -05:00
Dave Richer
f6acc1107c feature/IO-3096-GlobalNotifications - add Dayjs, minor packages on backend 2025-02-28 12:05:40 -05:00
Dave Richer
9b871149ac feature/IO-3096-GlobalNotifications - add Dayjs, minor packages on backend 2025-02-28 11:13:08 -05:00
Allan Carr
9a71779cfe IO-2561 Return Items Modal
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-02-27 18:42:43 -08:00
Dave Richer
5bd6f0453d feature/IO-3096-GlobalNotifications -Read Status Sync accross all clients. 2025-02-27 20:28:41 -05:00
Dave Richer
f6328d10f7 feature/IO-3096-GlobalNotifications -Read Status Sync accross all clients. 2025-02-27 20:16:33 -05:00
Dave Richer
3766c3d938 feature/IO-3096-GlobalNotifications - Adjust the Global Placement for notificationContext.jsx, removed adjustments to said location and duration to socket 2025-02-27 13:30:18 -05:00
92 changed files with 3966 additions and 2897 deletions

View File

@@ -88,7 +88,7 @@ jobs:
name: Install Dependencies
command: npm i
- run: npm run build:production:imex
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:production:imex
- aws-cli/setup:
aws_access_key_id: AWS_ACCESS_KEY_ID
@@ -151,7 +151,7 @@ jobs:
rome-app-build:
docker:
- image: cimg/node:22.13.1
resource_class: large
working_directory: ~/repo/client
steps:
@@ -161,7 +161,7 @@ jobs:
name: Install Dependencies
command: npm i
- run: npm run build:production:rome
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:production:rome
- aws-cli/setup:
aws_access_key_id: AWS_ACCESS_KEY_ID
@@ -209,7 +209,7 @@ jobs:
test-rome-app-build:
docker:
- image: cimg/node:22.13.1
resource_class: large
working_directory: ~/repo/client
steps:
@@ -219,7 +219,7 @@ jobs:
name: Install Dependencies
command: npm i
- run: npm run build:test:rome
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:test:rome
- aws-cli/setup:
aws_access_key_id: AWS_ACCESS_KEY_ID
@@ -277,7 +277,7 @@ jobs:
name: Install Dependencies
command: npm i
- run: npm run build:test:imex
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:test:imex
- aws-s3/sync:
from: build
@@ -298,7 +298,7 @@ jobs:
name: Install Dependencies
command: npm i
- run: npm run build:test:imex
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:test:imex
- aws-cli/setup:
aws_access_key_id: AWS_ACCESS_KEY_ID

442
client/package-lock.json generated
View File

@@ -16,13 +16,14 @@
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.6.0",
"@sentry/cli": "^2.42.2",
"@sentry/react": "^7.114.0",
"@sentry/react": "^9.3.0",
"@sentry/vite-plugin": "^3.2.2",
"@splitsoftware/splitio-react": "^1.13.0",
"@tanem/react-nprogress": "^5.0.53",
"@vitejs/plugin-react": "^4.3.4",
"antd": "^5.24.2",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^3.3.0",
"apollo-link-sentry": "^4.1.0",
"autosize": "^6.0.1",
"axios": "^1.8.1",
"classnames": "^2.5.1",
@@ -91,7 +92,7 @@
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.21.0",
"@sentry/webpack-plugin": "^2.22.4",
"@sentry/webpack-plugin": "^3.2.2",
"@testing-library/cypress": "^10.0.2",
"browserslist": "^4.24.4",
"browserslist-to-esbuild": "^2.1.1",
@@ -5259,88 +5260,90 @@
"dev": true,
"license": "MIT"
},
"node_modules/@sentry-internal/feedback": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.120.3.tgz",
"integrity": "sha512-ewJJIQ0mbsOX6jfiVFvqMjokxNtgP3dNwUv+4nenN+iJJPQsM6a0ocro3iscxwVdbkjw5hY3BUV2ICI5Q0UWoA==",
"node_modules/@sentry-internal/browser-utils": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.3.0.tgz",
"integrity": "sha512-G3z4HCUyb5nJe03EPUhWjnaHqMDt4mOTFJDNha3DGoB51lMYojpQI1Qo1u6bY4qkWVSO1c+HqOU0RVsXoAchtQ==",
"license": "MIT",
"dependencies": {
"@sentry/core": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
"@sentry/core": "9.3.0"
},
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.3.0.tgz",
"integrity": "sha512-LQmIbQaATlN5QEwCD2Xt+7VKfwfR5W3dbn0jdF1x4hQFE/srdnOj60xMz/mj3tP5BxV552xJniGsyZ8lXHDb2A==",
"license": "MIT",
"dependencies": {
"@sentry/core": "9.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.3.0.tgz",
"integrity": "sha512-ZkH+Gahn89JygpuiFn26ZgAqJXHtnr+HjfQ2ONOFoWQHNH6X5wk75UTma55aYk1d8VcBPFoU6WjFhZoQ55SV1g==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "9.3.0",
"@sentry/core": "9.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-7.120.3.tgz",
"integrity": "sha512-s5xy+bVL1eDZchM6gmaOiXvTqpAsUfO7122DxVdEDMtwVq3e22bS2aiGa8CUgOiJkulZ+09q73nufM77kOmT/A==",
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.3.0.tgz",
"integrity": "sha512-MhDMJeRGa55a0D541+OzTFMWwbabthhDGbAL90/NpappfyeBbAiktmCNl0BFTZuRbCGrC2m1LLCqHegCVKW4fQ==",
"license": "MIT",
"dependencies": {
"@sentry/core": "7.120.3",
"@sentry/replay": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
"@sentry-internal/replay": "9.3.0",
"@sentry/core": "9.3.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@sentry-internal/tracing": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.120.3.tgz",
"integrity": "sha512-Ausx+Jw1pAMbIBHStoQ6ZqDZR60PsCByvHdw/jdH9AqPrNE9xlBSf9EwcycvmrzwyKspSLaB52grlje2cRIUMg==",
"license": "MIT",
"dependencies": {
"@sentry/core": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=8"
"node": ">=18"
}
},
"node_modules/@sentry/babel-plugin-component-annotate": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-2.23.0.tgz",
"integrity": "sha512-+uLqaCKeYmH/W2YUV1XHkFEtpHdx/aFjCQahPVsvXyqg13dfkR6jaygPL4DB5DJtUSmPFCUE3MEk9ZO5JlhJYg==",
"dev": true,
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.2.2.tgz",
"integrity": "sha512-D+SKQ266ra/wo87s9+UI/rKQi3qhGPCR8eSCDe0VJudhjHsqyNU+JJ5lnIGCgmZaWFTXgdBP/gdr1Iz1zqGs4Q==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/@sentry/browser": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.120.3.tgz",
"integrity": "sha512-i9vGcK9N8zZ/JQo1TCEfHHYZ2miidOvgOABRUc9zQKhYdcYQB2/LU1kqlj77Pxdxf4wOa9137d6rPrSn9iiBxg==",
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.3.0.tgz",
"integrity": "sha512-yPwWWQo/hpN63p0NGmk/Dd1Fx5CQRWNMfuV7dtfPBtg3vRjDecA9OLyK29AqK5h3Fl8FuJOyOqB87CvtXUqh5g==",
"license": "MIT",
"dependencies": {
"@sentry-internal/feedback": "7.120.3",
"@sentry-internal/replay-canvas": "7.120.3",
"@sentry-internal/tracing": "7.120.3",
"@sentry/core": "7.120.3",
"@sentry/integrations": "7.120.3",
"@sentry/replay": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
"@sentry-internal/browser-utils": "9.3.0",
"@sentry-internal/feedback": "9.3.0",
"@sentry-internal/replay": "9.3.0",
"@sentry-internal/replay-canvas": "9.3.0",
"@sentry/core": "9.3.0"
},
"engines": {
"node": ">=8"
"node": ">=18"
}
},
"node_modules/@sentry/bundler-plugin-core": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-2.23.0.tgz",
"integrity": "sha512-Qbw+jZFK63w+V193l0eCFKLzGba2Iu93Fx8kCRzZ3uqjky002H8U3pu4mKgcc11J+u8QTjfNZGUyXsxz0jv2mg==",
"dev": true,
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.2.2.tgz",
"integrity": "sha512-YGrtmqQ2jMixccX2slVG/Lw7pCGJL3DGB3clmY9mO8QBEBIN3/gEANiHJVWwRidpUOS/0b7yVVGAdwZ87oPwTg==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.18.5",
"@sentry/babel-plugin-component-annotate": "2.23.0",
"@sentry/cli": "2.39.1",
"@sentry/babel-plugin-component-annotate": "3.2.2",
"@sentry/cli": "2.42.2",
"dotenv": "^16.3.1",
"find-up": "^5.0.0",
"glob": "^9.3.2",
@@ -5351,181 +5354,6 @@
"node": ">= 14"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli": {
"version": "2.39.1",
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.39.1.tgz",
"integrity": "sha512-JIb3e9vh0+OmQ0KxmexMXg9oZsR/G7HMwxt5BUIKAXZ9m17Xll4ETXTRnRUBT3sf7EpNGAmlQk1xEmVN9pYZYQ==",
"dev": true,
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"https-proxy-agent": "^5.0.0",
"node-fetch": "^2.6.7",
"progress": "^2.0.3",
"proxy-from-env": "^1.1.0",
"which": "^2.0.2"
},
"bin": {
"sentry-cli": "bin/sentry-cli"
},
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@sentry/cli-darwin": "2.39.1",
"@sentry/cli-linux-arm": "2.39.1",
"@sentry/cli-linux-arm64": "2.39.1",
"@sentry/cli-linux-i686": "2.39.1",
"@sentry/cli-linux-x64": "2.39.1",
"@sentry/cli-win32-i686": "2.39.1",
"@sentry/cli-win32-x64": "2.39.1"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-darwin": {
"version": "2.39.1",
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.39.1.tgz",
"integrity": "sha512-kiNGNSAkg46LNGatfNH5tfsmI/kCAaPA62KQuFZloZiemTNzhy9/6NJP8HZ/GxGs8GDMxic6wNrV9CkVEgFLJQ==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-linux-arm": {
"version": "2.39.1",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.39.1.tgz",
"integrity": "sha512-DkENbxyRxUrfLnJLXTA4s5UL/GoctU5Cm4ER1eB7XN7p9WsamFJd/yf2KpltkjEyiTuplv0yAbdjl1KX3vKmEQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"os": [
"linux",
"freebsd"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-linux-arm64": {
"version": "2.39.1",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.39.1.tgz",
"integrity": "sha512-5VbVJDatolDrWOgaffsEM7znjs0cR8bHt9Bq0mStM3tBolgAeSDHE89NgHggfZR+DJ2VWOy4vgCwkObrUD6NQw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"os": [
"linux",
"freebsd"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-linux-i686": {
"version": "2.39.1",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.39.1.tgz",
"integrity": "sha512-pXWVoKXCRrY7N8vc9H7mETiV9ZCz+zSnX65JQCzZxgYrayQPJTc+NPRnZTdYdk5RlAupXaFicBI2GwOCRqVRkg==",
"cpu": [
"x86",
"ia32"
],
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"os": [
"linux",
"freebsd"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-linux-x64": {
"version": "2.39.1",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.39.1.tgz",
"integrity": "sha512-IwayNZy+it7FWG4M9LayyUmG1a/8kT9+/IEm67sT5+7dkMIMcpmHDqL8rWcPojOXuTKaOBBjkVdNMBTXy0mXlA==",
"cpu": [
"x64"
],
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"os": [
"linux",
"freebsd"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-win32-i686": {
"version": "2.39.1",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.39.1.tgz",
"integrity": "sha512-NglnNoqHSmE+Dz/wHeIVRnV2bLMx7tIn3IQ8vXGO5HWA2f8zYJGktbkLq1Lg23PaQmeZLPGlja3gBQfZYSG10Q==",
"cpu": [
"x86",
"ia32"
],
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-win32-x64": {
"version": "2.39.1",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.39.1.tgz",
"integrity": "sha512-xv0R2CMf/X1Fte3cMWie1NXuHmUyQPDBfCyIt6k6RPFPxAYUgcqgMPznYwVMwWEA1W43PaOkSn3d8ZylsDaETw==",
"cpu": [
"x64"
],
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/@sentry/bundler-plugin-core/node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@sentry/cli": {
"version": "2.42.2",
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.42.2.tgz",
@@ -5692,96 +5520,52 @@
}
},
"node_modules/@sentry/core": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.120.3.tgz",
"integrity": "sha512-vyy11fCGpkGK3qI5DSXOjgIboBZTriw0YDx/0KyX5CjIjDDNgp5AGgpgFkfZyiYiaU2Ww3iFuKo4wHmBusz1uA==",
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.3.0.tgz",
"integrity": "sha512-SxQ4z7wTkfguvYb2ctNEMU9kVAbhl9ymfjhLnrvtygTwL5soLqAKdco/lX/4P9K9Osgb2Dl6urQWRl+AhzKVbQ==",
"license": "MIT",
"dependencies": {
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/integrations": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.120.3.tgz",
"integrity": "sha512-6i/lYp0BubHPDTg91/uxHvNui427df9r17SsIEXa2eKDwQ9gW2qRx5IWgvnxs2GV/GfSbwcx4swUB3RfEWrXrQ==",
"license": "MIT",
"dependencies": {
"@sentry/core": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3",
"localforage": "^1.8.1"
},
"engines": {
"node": ">=8"
"node": ">=18"
}
},
"node_modules/@sentry/react": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-7.120.3.tgz",
"integrity": "sha512-BcpoK9dwblfb20xwjn/1DRtplvPEXFc3XCRkYSnTfnfZNU8yPOcVX4X2X0I8R+/gsg+MWiFOdEtXJ3FqpJiJ4Q==",
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.3.0.tgz",
"integrity": "sha512-/ruDHBHLDXmZoEHNCSjdekZr9+0pbOC5+BY1oABGoDXRISGyoenOBtAsX8TsaC9oJYhr16yKDFlYxzzQRhxDyg==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "7.120.3",
"@sentry/core": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3",
"@sentry/browser": "9.3.0",
"@sentry/core": "9.3.0",
"hoist-non-react-statics": "^3.3.2"
},
"engines": {
"node": ">=8"
"node": ">=18"
},
"peerDependencies": {
"react": "15.x || 16.x || 17.x || 18.x"
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"node_modules/@sentry/replay": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.120.3.tgz",
"integrity": "sha512-CjVq1fP6bpDiX8VQxudD5MPWwatfXk8EJ2jQhJTcWu/4bCSOQmHxnnmBM+GVn5acKUBCodWHBN+IUZgnJheZSg==",
"node_modules/@sentry/vite-plugin": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-3.2.2.tgz",
"integrity": "sha512-WSkHOhZszMrIE9zmx2l4JhMnMlZmN/yAoHyf59pwFLIMctuZak6lNPbTbIFkFHDzIJ9Nut5RAVsw1qjmWc1PTA==",
"license": "MIT",
"dependencies": {
"@sentry-internal/tracing": "7.120.3",
"@sentry/core": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
"@sentry/bundler-plugin-core": "3.2.2",
"unplugin": "1.0.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@sentry/types": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.120.3.tgz",
"integrity": "sha512-C4z+3kGWNFJ303FC+FxAd4KkHvxpNFYAFN8iMIgBwJdpIl25KZ8Q/VdGn0MLLUEHNLvjob0+wvwlcRBBNLXOow==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/utils": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.120.3.tgz",
"integrity": "sha512-UDAOQJtJDxZHQ5Nm1olycBIsz2wdGX8SdzyGVHmD8EOQYAeDZQyIlQYohDe9nazdIOQLZCIc3fU0G9gqVLkaGQ==",
"license": "MIT",
"dependencies": {
"@sentry/types": "7.120.3"
},
"engines": {
"node": ">=8"
"node": ">= 14"
}
},
"node_modules/@sentry/webpack-plugin": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-2.23.0.tgz",
"integrity": "sha512-WxYUbTt/tNfeDm9apeUDXXKs6bEuuVrgYJeCDPDzjGQdmLTsZnbLxcX/b+zr4pceyzZNFEJujk60rRWCjFZY3w==",
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-3.2.2.tgz",
"integrity": "sha512-6OkVKNOjKk8P9j7oh6svZ+kEP1i9YIHBC2aGWL2XsgeZTIrMBxJAXtOf+qSrfMAxEtibSroGVOMQc/y3WJTQtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sentry/bundler-plugin-core": "2.23.0",
"@sentry/bundler-plugin-core": "3.2.2",
"unplugin": "1.0.1",
"uuid": "^9.0.0"
},
@@ -6665,7 +6449,6 @@
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -6844,7 +6627,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@@ -6858,7 +6640,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -6881,9 +6662,9 @@
}
},
"node_modules/apollo-link-sentry": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/apollo-link-sentry/-/apollo-link-sentry-3.3.0.tgz",
"integrity": "sha512-wLffWmo5sRw3rHN1Ck6azM0oxObvtaBBf3AC8cLX4SxhyjmkRIagGDji6CFkyAhxupPz0b9/H1u4Ocx+63lNug==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/apollo-link-sentry/-/apollo-link-sentry-4.1.0.tgz",
"integrity": "sha512-wHiJXZ9OzmRbV3famykszz0E0SZyCBpa3Rt0EVrdOKDN9qQQaE63xQB2lFa3mUkZb95GMc+6ugPt8bVIkwsRPQ==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
@@ -6893,7 +6674,7 @@
},
"peerDependencies": {
"@apollo/client": "^3.2.3",
"@sentry/browser": "^7.41.0",
"@sentry/core": "^8.33.0",
"graphql": "15 - 16"
}
},
@@ -7457,7 +7238,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -7520,7 +7300,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -10678,7 +10457,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -10710,7 +10488,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
@@ -10867,7 +10644,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -11038,7 +10814,6 @@
"version": "9.3.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz",
"integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
@@ -11070,7 +10845,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -11080,7 +10854,6 @@
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz",
"integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
@@ -11570,12 +11343,6 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immer": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
@@ -11842,7 +11609,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@@ -11985,7 +11751,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -12039,7 +11804,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -12116,7 +11880,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -12728,15 +12491,6 @@
"integrity": "sha512-vLmhg7Gan7idyAKfc6pvCtNzvar4/eIzrVVk3hjNFH5+fGqyjD0gQRovdTrDl20wsmZhBtmZpcsR0tOfquwb8g==",
"license": "MIT"
},
"node_modules/lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
"integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -12788,20 +12542,10 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/localforage": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
"license": "Apache-2.0",
"dependencies": {
"lie": "3.1.1"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
@@ -13091,7 +12835,6 @@
"version": "0.30.8",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
"integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -13910,7 +13653,6 @@
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz",
"integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=8"
@@ -14091,7 +13833,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -14399,7 +14140,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
@@ -14415,7 +14155,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
@@ -14562,7 +14301,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -14597,7 +14335,6 @@
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
@@ -14614,14 +14351,12 @@
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/path-scurry/node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -18119,7 +17854,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -18655,7 +18389,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.0.1.tgz",
"integrity": "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.8.1",
@@ -18668,7 +18401,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@@ -18693,7 +18425,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -18706,7 +18437,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -18719,7 +18449,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@@ -19263,7 +18992,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
"integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
@@ -19273,7 +19001,6 @@
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.5.0.tgz",
"integrity": "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==",
"dev": true,
"license": "MIT"
},
"node_modules/websocket-driver": {
@@ -19887,7 +19614,6 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"

View File

@@ -15,13 +15,14 @@
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.6.0",
"@sentry/cli": "^2.42.2",
"@sentry/react": "^7.114.0",
"@sentry/react": "^9.3.0",
"@sentry/vite-plugin": "^3.2.2",
"@splitsoftware/splitio-react": "^1.13.0",
"@tanem/react-nprogress": "^5.0.53",
"@vitejs/plugin-react": "^4.3.4",
"antd": "^5.24.2",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^3.3.0",
"apollo-link-sentry": "^4.1.0",
"autosize": "^6.0.1",
"axios": "^1.8.1",
"classnames": "^2.5.1",
@@ -98,8 +99,7 @@
"test": "cypress open",
"eject": "react-scripts eject",
"madge": "madge --image ./madge-graph.svg --extensions js,jsx,ts,tsx --circular .",
"eulaize": "node src/utils/eulaize.js",
"sentry:sourcemaps:imex": "sentry-cli sourcemaps inject --org imex --project imexonline ./build && sentry-cli sourcemaps upload --org imex --project imexonline ./build"
"eulaize": "node src/utils/eulaize.js"
},
"browserslist": {
"production": [
@@ -127,7 +127,7 @@
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.21.0",
"@sentry/webpack-plugin": "^2.22.4",
"@sentry/webpack-plugin": "^3.2.2",
"@testing-library/cypress": "^10.0.2",
"browserslist": "^4.24.4",
"browserslist-to-esbuild": "^2.1.1",

View File

@@ -21,7 +21,7 @@ import "./App.styles.scss";
import Eula from "../components/eula/eula.component";
import InstanceRenderMgr from "../utils/instanceRenderMgr";
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
import { SocketProvider } from "../contexts/SocketIO/socketContext.jsx";
import { SocketProvider } from "../contexts/SocketIO/useSocket.jsx";
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
@@ -201,7 +201,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
path="/manage/*"
element={
<ErrorBoundary>
<SocketProvider bodyshop={bodyshop} navigate={navigate}>
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
</ErrorBoundary>
@@ -213,7 +213,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
path="/tech/*"
element={
<ErrorBoundary>
<SocketProvider bodyshop={bodyshop} navigate={navigate}>
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
</ErrorBoundary>

View File

@@ -180,3 +180,13 @@
.muted-button:hover {
color: darkgrey;
}
.notification-alert-unordered-list {
cursor: pointer;
padding: 0;
margin: 0;
.notification-alert-unordered-list-item {
margin-right: 0;
}
}

View File

@@ -3,7 +3,7 @@ import { getToken } from "@firebase/messaging";
import axios from "axios";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useSocket } from "../../contexts/SocketIO/socketContext";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { messaging, requestForToken } from "../../firebase/firebase.utils";
import ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss";

View File

@@ -3,7 +3,7 @@ import { Button } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";

View File

@@ -4,7 +4,7 @@ import { Link } from "react-router-dom";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";

View File

@@ -3,7 +3,7 @@ import axios from "axios";
import { useCallback, useEffect, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/socketContext";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";

View File

@@ -4,7 +4,7 @@ import { Input, Spin, Tag, Tooltip } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";

View File

@@ -5,7 +5,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser

View File

@@ -7,7 +7,7 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({

View File

@@ -12,7 +12,7 @@ import ChatConversationListComponent from "../chat-conversation-list/chat-conver
import ChatConversationContainer from "../chat-conversation/chat-conversation.container";
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import "./chat-popup.styles.scss";

View File

@@ -8,7 +8,7 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
import ChatTagRo from "./chat-tag-ro.component";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";

View File

@@ -123,7 +123,7 @@ class ErrorBoundary extends React.Component {
<Row>
<Col offset={6} span={12}>
<Collapse bordered={false}>
<Collapse.Panel header={t("general.labels.errors")}>
<Collapse.Panel key="errors-panel" header={t("general.labels.errors")}>
<div>
<strong>{this.state.error.message}</strong>
</div>

View File

@@ -78,9 +78,7 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
} catch (err) {
notification.error({
message: t("eula.errors.acceptance.message"),
description: t("eula.errors.acceptance.description"),
placement: "bottomRight",
duration: 5000
description: t("eula.errors.acceptance.description")
});
console.log(`${t("eula.errors.acceptance.message")}`);
console.dir({

View File

@@ -1,4 +1,16 @@
import Icon, {
import { Badge, Layout, Menu, Spin } from "antd";
import { useTranslation } from "react-i18next";
import { useEffect, useRef, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { Link } from "react-router-dom";
import { useQuery } from "@apollo/client";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import {
BankFilled,
BarChartOutlined,
BellFilled,
@@ -26,35 +38,21 @@ import Icon, {
UnorderedListOutlined,
UserOutlined
} from "@ant-design/icons";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Badge, Layout, Menu, Space, Spin } from "antd";
import { useTranslation } from "react-i18next";
import { BsKanban } from "react-icons/bs";
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
import { FiLogOut } from "react-icons/fi";
import { GiPayMoney, GiPlayerTime, GiSettingsKnobs } from "react-icons/gi";
import { IoBusinessOutline } from "react-icons/io5";
import { RiSurveyLine } from "react-icons/ri";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions";
import { signOutStart } from "../../redux/user/user.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
import { useState, useEffect } from "react";
import { debounce } from "lodash";
import { useQuery } from "@apollo/client";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
// Used to Determine if the Header is in Mobile Mode, and to toggle the multiple menus
const HEADER_MOBILE_BREAKPOINT = 576;
import day from "../../utils/day.js";
// Redux mappings
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
recentItems: selectRecentItems,
@@ -63,43 +61,13 @@ const mapStateToProps = createStructuredSelector({
});
const mapDispatchToProps = (dispatch) => ({
setBillEnterContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "billEnter"
})
),
setTimeTicketContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "timeTicket"
})
),
setPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "payment" })),
setReportCenterContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "reportCenter"
})
),
setBillEnterContext: (context) => dispatch(setModalContext({ context, modal: "billEnter" })),
setTimeTicketContext: (context) => dispatch(setModalContext({ context, modal: "timeTicket" })),
setPaymentContext: (context) => dispatch(setModalContext({ context, modal: "payment" })),
setReportCenterContext: (context) => dispatch(setModalContext({ context, modal: "reportCenter" })),
signOutStart: () => dispatch(signOutStart()),
setCardPaymentContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "cardPayment"
})
),
setTaskUpsertContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "taskUpsert"
})
)
setCardPaymentContext: (context) => dispatch(setModalContext({ context, modal: "cardPayment" })),
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
});
function Header({
@@ -125,10 +93,10 @@ function Header({
});
const { t } = useTranslation();
const { isConnected } = useSocket();
const { isConnected, scenarioNotificationsOn } = useSocket();
const [notificationVisible, setNotificationVisible] = useState(false);
const baseTitleRef = useRef(document.title || "");
const lastSetTitleRef = useRef("");
const userAssociationId = bodyshop?.associations?.[0]?.id;
const {
@@ -138,57 +106,68 @@ function Header({
} = useQuery(GET_UNREAD_COUNT, {
variables: { associationid: userAssociationId },
fetchPolicy: "network-only",
pollInterval: isConnected ? 0 : 30000, // Poll only if socket is down
skip: !userAssociationId // Skip query if no userAssociationId
pollInterval: isConnected ? 0 : day.duration(60, "seconds").asMilliseconds(),
skip: !userAssociationId || !scenarioNotificationsOn
});
const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count ?? 0;
// Initial fetch and socket status handling
useEffect(() => {
if (userAssociationId) {
refetchUnread().catch((e) => console.error(`Something went wrong fetching unread notifications: ${e?.message}`));
refetchUnread().catch((e) => console.error(`Error fetching unread notifications: ${e?.message}`));
}
}, [refetchUnread, userAssociationId]);
useEffect(() => {
if (!isConnected && !unreadLoading && userAssociationId) {
refetchUnread().catch((e) => console.error(`Something went wrong fetching unread notifications: ${e?.message}`));
refetchUnread().catch((e) => console.error(`Error fetching unread notifications: ${e?.message}`));
}
}, [isConnected, unreadLoading, refetchUnread, userAssociationId]);
// Keep The unread count in the title.
useEffect(() => {
const updateTitle = () => {
const currentTitle = document.title;
// Check if the current title differs from what we last set
if (currentTitle !== lastSetTitleRef.current) {
// Extract base title by removing any unread count prefix
const baseTitleMatch = currentTitle.match(/^\(\d+\)\s*(.*)$/);
baseTitleRef.current = baseTitleMatch ? baseTitleMatch[1] : currentTitle;
}
// Apply unread count to the base title
const newTitle = unreadCount > 0 ? `(${unreadCount}) ${baseTitleRef.current}` : baseTitleRef.current;
// Only update if the title has changed to avoid unnecessary DOM writes
if (document.title !== newTitle) {
document.title = newTitle;
lastSetTitleRef.current = newTitle; // Store what we set
}
};
// Initial update
updateTitle();
// Poll every 100ms to catch child component changes
const interval = setInterval(updateTitle, 100);
// Cleanup
return () => {
clearInterval(interval);
document.title = baseTitleRef.current; // Reset to base title on unmount
};
}, [unreadCount]); // Re-run when unreadCount changes
const handleNotificationClick = (e) => {
setNotificationVisible(!notificationVisible);
if (handleMenuClick) handleMenuClick(e);
};
const [isMobile, setIsMobile] = useState(() => {
const effectiveWidth = window.innerWidth / (window.devicePixelRatio || 1);
return effectiveWidth <= HEADER_MOBILE_BREAKPOINT;
});
const handleResize = debounce(() => {
const effectiveWidth = window.innerWidth / (window.devicePixelRatio || 1);
setIsMobile(effectiveWidth <= HEADER_MOBILE_BREAKPOINT);
}, 200);
useEffect(() => {
window.addEventListener("resize", handleResize);
window.addEventListener("orientationchange", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
window.removeEventListener("orientationchange", handleResize);
handleResize.cancel(); // Cancel any pending debounced calls on cleanup
};
}, [handleResize]);
// Accounting children setup (unchanged)
const accountingChildren = [];
accountingChildren.push(
const accountingChildren = [
{
key: "bills",
id: "header-accounting-bills",
icon: <Icon component={FaFileInvoiceDollar} />,
icon: <FaFileInvoiceDollar />,
label: (
<Link to="/manage/bills">
<LockWrapper featureName="bills" bodyshop={bodyshop}>
@@ -200,42 +179,31 @@ function Header({
{
key: "enterbills",
id: "header-accounting-enterbills",
icon: <Icon component={GiPayMoney} />,
icon: <GiPayMoney />,
label: (
<Space>
<LockWrapper featureName="bills" bodyshop={bodyshop}>
{t("menus.header.enterbills")}
</LockWrapper>
</Space>
<LockWrapper featureName="bills" bodyshop={bodyshop}>
{t("menus.header.enterbills")}
</LockWrapper>
),
onClick: () => {
onClick: () =>
HasFeatureAccess({ featureName: "bills", bodyshop }) &&
setBillEnterContext({
actions: {},
context: {}
});
}
}
);
if (Simple_Inventory.treatment === "on") {
accountingChildren.push(
{
type: "divider"
},
{
key: "inventory",
id: "header-accounting-inventory",
icon: <Icon component={FaFileInvoiceDollar} />,
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
}
);
}
accountingChildren.push(
{
type: "divider"
setBillEnterContext({
actions: {},
context: {}
})
},
...(Simple_Inventory.treatment === "on"
? [
{ type: "divider" },
{
key: "inventory",
id: "header-accounting-inventory",
icon: <FaFileInvoiceDollar />,
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
}
]
: []),
{ type: "divider" },
{
key: "allpayments",
id: "header-accounting-allpayments",
@@ -251,41 +219,31 @@ function Header({
{
key: "enterpayments",
id: "header-accounting-enterpayments",
icon: <Icon component={FaCreditCard} />,
icon: <FaCreditCard />,
label: (
<LockWrapper featureName="payments" bodyshop={bodyshop}>
{t("menus.header.enterpayment")}
</LockWrapper>
),
onClick: () => {
onClick: () =>
HasFeatureAccess({ featureName: "payments", bodyshop }) &&
setPaymentContext({
actions: {},
context: null
});
}
}
);
if (ImEXPay.treatment === "on") {
accountingChildren.push({
key: "entercardpayments",
id: "header-accounting-entercardpayments",
icon: <Icon component={FaCreditCard} />,
label: t("menus.header.entercardpayment"),
onClick: () => {
setCardPaymentContext({
setPaymentContext({
actions: {},
context: {}
});
}
});
}
accountingChildren.push(
{
type: "divider"
context: null
})
},
...(ImEXPay.treatment === "on"
? [
{
key: "entercardpayments",
id: "header-accounting-entercardpayments",
icon: <FaCreditCard />,
label: t("menus.header.entercardpayment"),
onClick: () => setCardPaymentContext({ actions: {}, context: {} })
}
]
: []),
{ type: "divider" },
{
key: "timetickets",
id: "header-accounting-timetickets",
@@ -297,133 +255,124 @@ function Header({
</LockWrapper>
</Link>
)
}
);
if (bodyshop?.md_tasks_presets?.use_approvals) {
accountingChildren.push({
key: "ttapprovals",
id: "header-accounting-ttapprovals",
icon: <FieldTimeOutlined />,
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
});
}
accountingChildren.push(
},
...(bodyshop?.md_tasks_presets?.use_approvals
? [
{
key: "ttapprovals",
id: "header-accounting-ttapprovals",
icon: <FieldTimeOutlined />,
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
}
]
: []),
{
key: "entertimetickets",
icon: <Icon component={GiPlayerTime} />,
id: "header-accounting-entertimetickets",
icon: <GiPlayerTime />,
label: (
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
{t("menus.header.entertimeticket")}
</LockWrapper>
),
id: "header-accounting-entertimetickets",
onClick: () => {
onClick: () =>
HasFeatureAccess({ featureName: "timetickets", bodyshop }) &&
setTimeTicketContext({
actions: {},
context: {
created_by: currentUser.displayName
? currentUser.email.concat(" | ", currentUser.displayName)
: currentUser.email
}
});
}
setTimeTicketContext({
actions: {},
context: {
created_by: currentUser.displayName
? `${currentUser.email} | ${currentUser.displayName}`
: currentUser.email
}
})
},
{ type: "divider" },
{
type: "divider"
}
);
const accountingExportChildren = [
{
key: "receivables",
id: "header-accounting-receivables",
key: "accountingexport",
id: "header-accounting-export",
icon: <ExportOutlined />,
label: (
<Link to="/manage/accounting/receivables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-receivables")}
</LockWrapper>
</Link>
)
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export")}
</LockWrapper>
),
children: [
{
key: "receivables",
id: "header-accounting-receivables",
label: (
<Link to="/manage/accounting/receivables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-receivables")}
</LockWrapper>
</Link>
)
},
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) ||
DmsAp.treatment === "on"
? [
{
key: "payables",
id: "header-accounting-payables",
label: (
<Link to="/manage/accounting/payables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payables")}
</LockWrapper>
</Link>
)
}
]
: []),
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))
? [
{
key: "payments",
id: "header-accounting-payments",
label: (
<Link to="/manage/accounting/payments">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payments")}
</LockWrapper>
</Link>
)
}
]
: []),
{ type: "divider" },
{
key: "exportlogs",
id: "header-accounting-exportlogs",
label: (
<Link to="/manage/accounting/exportlogs">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export-logs")}
</LockWrapper>
</Link>
)
}
]
}
];
if (!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) || DmsAp.treatment === "on") {
accountingExportChildren.push({
key: "payables",
id: "header-accounting-payables",
label: (
<Link to="/manage/accounting/payables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payables")}
</LockWrapper>
</Link>
)
});
}
if (!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))) {
accountingExportChildren.push({
key: "payments",
id: "header-accounting-payments",
label: (
<Link to="/manage/accounting/payments">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payments")}
</LockWrapper>
</Link>
)
});
}
accountingExportChildren.push(
{
type: "divider"
},
{
key: "exportlogs",
id: "header-accounting-exportlogs",
label: (
<Link to="/manage/accounting/exportlogs">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export-logs")}
</LockWrapper>
</Link>
)
}
);
accountingChildren.push({
key: "accountingexport",
id: "header-accounting-export",
icon: <ExportOutlined />,
label: (
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export")}
</LockWrapper>
),
children: accountingExportChildren
});
// Define all menu items
const menuItems = [
// Left menu items (includes original navigation items)
const leftMenuItems = [
{
key: "home",
icon: <HomeFilled />,
id: "header-home",
icon: <HomeFilled />,
label: <Link to="/manage/">{t("menus.header.home")}</Link>
},
{
key: "schedule",
id: "header-schedule",
icon: <Icon component={FaCalendarAlt} />,
icon: <FaCalendarAlt />,
label: <Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
},
{
key: "jobssubmenu",
id: "header-jobs",
icon: <Icon component={FaCarCrash} />,
icon: <FaCarCrash />,
label: t("menus.header.jobs"),
children: [
{
@@ -456,20 +405,14 @@ function Header({
icon: <FileAddOutlined />,
label: <Link to="/manage/jobs/new">{t("menus.header.newjob")}</Link>
},
{
type: "divider",
id: "header-jobs-divider"
},
{ type: "divider" },
{
key: "alljobs",
id: "header-all-jobs",
icon: <UnorderedListOutlined />,
label: <Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
},
{
type: "divider",
id: "header-jobs-divider2"
},
{ type: "divider" },
{
key: "productionlist",
id: "header-production-list",
@@ -479,7 +422,7 @@ function Header({
{
key: "productionboard",
id: "header-production-board",
icon: <Icon component={BsKanban} />,
icon: <BsKanban />,
label: (
<Link to="/manage/production/board">
<LockWrapper featureName="visualboard" bodyshop={bodyshop}>
@@ -488,10 +431,7 @@ function Header({
</Link>
)
},
{
type: "divider",
id: "header-jobs-divider3"
},
{ type: "divider" },
{
key: "scoreboard",
id: "header-scoreboard",
@@ -508,8 +448,8 @@ function Header({
},
{
key: "customers",
icon: <UserOutlined />,
id: "header-customers",
icon: <UserOutlined />,
label: t("menus.header.customers"),
children: [
{
@@ -614,12 +554,7 @@ function Header({
id: "header-create-task",
icon: <PlusCircleOutlined />,
label: t("menus.header.create_task"),
onClick: () => {
setTaskUpsertContext({
actions: {},
context: {}
});
}
onClick: () => setTaskUpsertContext({ actions: {}, context: {} })
},
{
key: "mytasks",
@@ -644,7 +579,7 @@ function Header({
{
key: "shop",
id: "header-shop",
icon: <Icon component={GiSettingsKnobs} />,
icon: <GiSettingsKnobs />,
label: <Link to="/manage/shop?tab=info">{t("menus.header.shop_config")}</Link>
},
{
@@ -662,23 +597,18 @@ function Header({
id: "header-reportcenter",
icon: <BarChartOutlined />,
label: t("menus.header.reportcenter"),
onClick: () => {
setReportCenterContext({
actions: {},
context: {}
});
}
onClick: () => setReportCenterContext({ actions: {}, context: {} })
},
{
key: "shop-vendors",
id: "header-shop-vendors",
icon: <Icon component={IoBusinessOutline} />,
icon: <IoBusinessOutline />,
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
},
{
key: "shop-csi",
id: "header-shop-csi",
icon: <Icon component={RiSurveyLine} />,
icon: <RiSurveyLine />,
label: (
<Link to="/manage/shop/csi">
<LockWrapper featureName="export" bodyshop={bodyshop}>
@@ -689,23 +619,11 @@ function Header({
}
]
},
// Right-aligned items on desktop, merged on mobile
{
key: "notifications",
icon: unreadLoading ? (
<Spin size="small" />
) : (
<Badge count={unreadCount}>
<BellFilled />
</Badge>
),
id: "header-notifications",
onClick: handleNotificationClick
},
{
key: "recent",
icon: <ClockCircleFilled />,
id: "header-recent",
icon: <ClockCircleFilled />,
label: t("menus.header.recent"),
children: recentItems.map((i, idx) => ({
key: idx,
id: `header-recent-${idx}`,
@@ -714,13 +632,14 @@ function Header({
},
{
key: "user",
id: "header-user",
icon: <UserOutlined />,
// label: currentUser.displayName || currentUser.email || t("general.labels.unknown"),
label: t("menus.currentuser.profile"),
children: [
{
key: "signout",
id: "header-signout",
icon: <Icon component={FiLogOut} />,
icon: <FiLogOut />,
danger: true,
label: t("user.actions.signout"),
onClick: () => signOutStart()
@@ -728,32 +647,25 @@ function Header({
{
key: "help",
id: "header-help",
icon: <Icon component={QuestionCircleFilled} />,
icon: <QuestionCircleFilled />,
label: t("menus.header.help"),
onClick: () => {
window.open("https://help.imex.online/", "_blank");
}
onClick: () => window.open("https://help.imex.online/", "_blank")
},
...(InstanceRenderManager({
imex: true,
rome: false
})
...(InstanceRenderManager({ imex: true, rome: false })
? [
{
key: "rescue",
id: "header-rescue",
icon: <Icon component={CarFilled} />,
icon: <CarFilled />,
label: t("menus.header.rescueme"),
onClick: () => {
window.open("https://imexrescue.com/", "_blank");
}
onClick: () => window.open("https://imexrescue.com/", "_blank")
}
]
: []),
{
key: "shiftclock",
id: "header-shiftclock",
icon: <Icon component={GiPlayerTime} />,
icon: <GiPlayerTime />,
label: (
<Link to="/manage/shiftclock">
<LockWrapper featureName="export" bodyshop={bodyshop}>
@@ -772,57 +684,68 @@ function Header({
}
];
// Notifications item (always on the right)
const notificationItem = scenarioNotificationsOn
? [
{
key: "notifications",
id: "header-notifications",
icon: unreadLoading ? (
<Spin size="small" />
) : (
<Badge offset={[8, 0]} size="small" count={unreadCount}>
<BellFilled />
</Badge>
),
onClick: handleNotificationClick
}
]
: [];
return (
<Layout.Header style={{ padding: 0 }}>
{isMobile ? (
<Layout.Header style={{ padding: 0, background: "#001529" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
height: "100%",
overflow: "hidden"
}}
>
<Menu
mode="horizontal"
theme="dark"
selectedKeys={[selectedHeader]}
onClick={handleMenuClick}
subMenuCloseDelay={0.3}
items={menuItems}
style={{ width: "100%" }}
/>
) : (
<div
items={leftMenuItems}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
width: "100%"
flex: "1 1 auto",
minWidth: 0,
overflowX: "auto",
borderBottom: "none",
background: "transparent"
}}
>
/>
{scenarioNotificationsOn && (
<Menu
mode="horizontal"
theme="dark"
selectedKeys={[selectedHeader]}
onClick={handleMenuClick}
subMenuCloseDelay={0.3}
items={menuItems.slice(0, -3)}
style={{
flex: "0 1 auto",
justifyContent: "flex-start",
minWidth: 0,
overflow: "visible"
}}
items={notificationItem}
style={{ flex: "0 0 auto", minWidth: 0, borderBottom: "none", background: "transparent" }}
/>
<div style={{ flex: "1 0 0" }} />
<Menu
mode="horizontal"
theme="dark"
selectedKeys={[selectedHeader]}
onClick={handleMenuClick}
subMenuCloseDelay={0.3}
items={menuItems.slice(-3)}
style={{
flex: "0 0 auto",
justifyContent: "flex-end",
overflow: "visible"
}}
/>
<NotificationCenterContainer visible={notificationVisible} onClose={() => setNotificationVisible(false)} />
</div>
)}
</div>
{scenarioNotificationsOn && (
<NotificationCenterContainer
visible={notificationVisible}
onClose={() => setNotificationVisible(false)}
unreadCount={unreadCount}
/>
)}
</Layout.Header>
);

View File

@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { setModalContext } from "../../redux/modals/modals.actions";

View File

@@ -216,7 +216,7 @@ export function JobCloseRoGuardContainer({ job, jobRO, bodyshop, form }) {
</Form.Item>
</Collapse.Panel>
<Collapse.Panel header={t("jobs.labels.performance")}>
<Collapse.Panel key="job-performance" header={t("jobs.labels.performance")}>
<Row gutter={[32, 32]}>
<Col className="ro-guard-col" span={24}>
<JobCloseRoGuardTtLifecycle job={job} />

View File

@@ -21,6 +21,8 @@ import JobDetailCardsInsuranceComponent from "./job-detail-cards.insurance.compo
import JobDetailCardsNotesComponent from "./job-detail-cards.notes.component";
import JobDetailCardsPartsComponent from "./job-detail-cards.parts.component";
import JobDetailCardsTotalsComponent from "./job-detail-cards.totals.component";
import JobWatcherToggleContainer from "../job-watcher-toggle/job-watcher-toggle.container.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -37,6 +39,7 @@ const span = {
};
export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
const { scenarioNotificationsOn } = useSocket();
const selectedBreakpoint = Object.entries(Grid.useBreakpoint())
.filter((screen) => !!screen[1])
.slice(-1)[0];
@@ -78,7 +81,12 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
{data ? (
<Card
title={
<Link to={`/manage/jobs/${data.jobs_by_pk.id}`}>{data.jobs_by_pk.ro_number || t("general.labels.na")}</Link>
<Space>
{scenarioNotificationsOn && <JobWatcherToggleContainer job={data.jobs_by_pk} />}
<Link to={`/manage/jobs/${data.jobs_by_pk.id}`}>
{data.jobs_by_pk.ro_number || t("general.labels.na")}
</Link>
</Space>
}
extra={
<Space wrap>
@@ -122,7 +130,11 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
</Col>
{!bodyshop.uselocalmediaserver && (
<Col {...span}>
<JobDetailCardsDocumentsComponent loading={loading} data={data ? data.jobs_by_pk : null} bodyshop={bodyshop} />
<JobDetailCardsDocumentsComponent
loading={loading}
data={data ? data.jobs_by_pk : null}
bodyshop={bodyshop}
/>
</Col>
)}
<Col {...span}>

View File

@@ -69,7 +69,7 @@ export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
<Card title="DEVELOPMENT USE ONLY">
<JobCalculateTotals job={job} disabled={jobRO} />
<Collapse>
<Collapse.Panel header="JSON Tree Totals">
<Collapse.Panel key="json-totals" header="JSON Tree Totals">
<div>
<pre>
{JSON.stringify(

View File

@@ -0,0 +1,154 @@
import React from "react";
import { EyeFilled, EyeOutlined, UserOutlined } from "@ant-design/icons";
import { Avatar, Button, Divider, List, Popover, Select, Tooltip, Typography } from "antd";
import { useTranslation } from "react-i18next";
import EmployeeSearchSelectComponent from "../../components/employee-search-select/employee-search-select.component.jsx";
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component.jsx";
import { BiSolidTrash } from "react-icons/bi";
const { Text } = Typography;
export default function JobWatcherToggleComponent({
jobWatchers,
isWatching,
watcherLoading,
adding,
removing,
open,
setOpen,
selectedWatcher,
setSelectedWatcher,
selectedTeam,
bodyshop,
Enhanced_Payroll,
handleToggleSelf,
handleRemoveWatcher,
handleWatcherSelect,
handleTeamSelect
}) {
const { t } = useTranslation();
const handleRenderItem = (watcher) => {
// Check if watcher is defined and has user_email
if (!watcher || !watcher.user_email) {
return null; // Skip rendering invalid watchers
}
const employee = bodyshop?.employees?.find((e) => e.user_email === watcher.user_email);
const displayName = employee ? `${employee.first_name} ${employee.last_name}` : watcher.user_email;
return (
<List.Item
actions={[
<Button
type="default"
danger
size="medium"
icon={<BiSolidTrash />}
onClick={() => handleRemoveWatcher(watcher.user_email)}
disabled={adding || removing} // Optional: Disable button during mutations
>
{t("notifications.actions.remove")}
</Button>
]}
>
<List.Item.Meta
avatar={<Avatar icon={<UserOutlined />} />}
title={<Text>{displayName}</Text>}
description={watcher.user_email}
/>
</List.Item>
);
};
const popoverContent = (
<div style={{ width: "30em" }}>
<List>
<List.Item
actions={[
<Button
type={isWatching ? "primary" : "default"}
danger={!isWatching}
icon={isWatching ? <EyeOutlined /> : <EyeFilled />}
size="medium"
onClick={handleToggleSelf}
loading={adding || removing}
>
{isWatching ? t("notifications.labels.unwatch") : t("notifications.labels.watch")}
</Button>
]}
>
<List.Item.Meta>
<Text type="secondary" style={{ marginBottom: 8, display: "block" }}>
{t("notifications.labels.watching-issue")}
</Text>
</List.Item.Meta>
</List.Item>
</List>
{watcherLoading ? (
<LoadingSpinner />
) : jobWatchers && jobWatchers.length > 0 ? (
<List dataSource={jobWatchers} renderItem={handleRenderItem} />
) : (
<Text type="secondary">{t("notifications.labels.no-watchers")}</Text>
)}
<Divider />
<Text type="secondary">{t("notifications.labels.add-watchers")}</Text>
<EmployeeSearchSelectComponent
style={{ minWidth: "100%" }}
options={
bodyshop?.employees?.filter((e) =>
jobWatchers.every((w) => w.user_email !== e.user_email && e.active && e.user_email)
) || []
}
placeholder={t("notifications.labels.employee-search")}
value={selectedWatcher}
onChange={(value) => {
setSelectedWatcher(value);
handleWatcherSelect(value);
}}
/>
{Enhanced_Payroll && bodyshop?.employee_teams?.length > 0 && (
<>
<Divider />
<Text type="secondary">{t("notifications.labels.add-watchers-team")}</Text>
<Select
showSearch
style={{ minWidth: "100%" }}
placeholder={t("notifications.labels.teams-search")}
value={selectedTeam}
onChange={handleTeamSelect}
options={
bodyshop?.employee_teams?.map((team) => {
const teamMembers = team.employee_team_members
.map((member) => {
const employee = bodyshop?.employees?.find((e) => e.id === member.employeeid);
return employee?.user_email && employee?.active ? employee.user_email : null;
})
.filter(Boolean);
return {
value: JSON.stringify(teamMembers),
label: team.name
};
}) || []
}
/>
</>
)}
</div>
);
return (
<Popover placement="rightTop" content={popoverContent} trigger="click" open={open} onOpenChange={setOpen}>
<Tooltip title={t("notifications.tooltips.job-watchers")}>
<Button
shape="circle"
type={isWatching ? "primary" : "default"}
icon={isWatching ? <EyeFilled /> : <EyeOutlined />}
loading={watcherLoading}
/>
</Tooltip>
</Popover>
);
}

View File

@@ -0,0 +1,219 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery } from "@apollo/client";
import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../graphql/jobs.queries.js";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
const {
treatments: { Enhanced_Payroll }
} = useSplitTreatments({
attributes: {},
names: ["Enhanced_Payroll"],
splitKey: bodyshop && bodyshop.imexshopid
});
const userEmail = currentUser.email;
const jobid = job.id;
const [open, setOpen] = useState(false);
const [selectedWatcher, setSelectedWatcher] = useState(null);
const [selectedTeam, setSelectedTeam] = useState(null);
// Fetch current watchers with refetch capability
const {
data: watcherData,
loading: watcherLoading,
refetch
} = useQuery(GET_JOB_WATCHERS, {
variables: { jobid },
fetchPolicy: "cache-and-network" // Ensure fresh data from server
});
// Refetch jobWatchers when the popover opens (open changes to true)
useEffect(() => {
if (open) {
refetch().catch((err) =>
console.error(`Something went wrong fetching Notification Watchers on popover open: ${err?.message}`, {
stack: err?.stack
})
);
}
}, [open, refetch]);
const jobWatchers = useMemo(() => (watcherData?.job_watchers ? [...watcherData.job_watchers] : []), [watcherData]);
const isWatching = jobWatchers.some((w) => w.user_email === userEmail);
const [addWatcher, { loading: adding }] = useMutation(ADD_JOB_WATCHER, {
onCompleted: () =>
refetch().catch((err) =>
console.error(`Something went wrong fetching Notification Watchers after add: ${err?.message}`, {
stack: err?.stack
})
),
onError: (err) => {
if (err.graphQLErrors && err.graphQLErrors.length > 0) {
const errorMessage = err.graphQLErrors[0].message;
if (
errorMessage.includes("Uniqueness violation") ||
errorMessage.includes("idx_job_watchers_jobid_user_email_unique")
) {
console.warn("Watcher already exists for this job and user.");
refetch().catch((err) =>
console.error(
`Something went wrong fetching Notification Watchers after uniqueness violation: ${err?.message}`,
{ stack: err?.stack }
)
); // Sync with server to ensure UI reflects actual state
} else {
console.error(`Error adding job watcher: ${errorMessage}`);
}
} else {
console.error(`Unexpected error adding job watcher: ${err.message || JSON.stringify(err)}`);
}
},
update(cache, { data }) {
if (!data || !data.insert_job_watchers_one) {
console.warn("No data or insert_job_watchers_one returned from mutation, skipping cache update.");
refetch().catch((err) =>
console.error(`Something went wrong updating Notification Watchers after add: ${err?.message}`, {
stack: err?.stack
})
);
return;
}
const insert_job_watchers_one = data.insert_job_watchers_one;
const existingData = cache.readQuery({
query: GET_JOB_WATCHERS,
variables: { jobid }
});
cache.writeQuery({
query: GET_JOB_WATCHERS,
variables: { jobid },
data: {
...existingData,
job_watchers: [...(existingData?.job_watchers || []), insert_job_watchers_one]
}
});
}
});
const [removeWatcher, { loading: removing }] = useMutation(REMOVE_JOB_WATCHER, {
onCompleted: () =>
refetch().catch((err) =>
console.error(`Something went wrong fetching Notification Watchers after remove: ${err?.message}`, {
stack: err?.stack
})
), // Refetch to sync with server after success
onError: (err) => console.error(`Error removing job watcher: ${err.message}`),
update(cache, { data: { delete_job_watchers } }) {
const existingData = cache.readQuery({
query: GET_JOB_WATCHERS,
variables: { jobid }
});
const deletedWatcher = delete_job_watchers.returning[0];
const updatedWatchers = deletedWatcher
? (existingData?.job_watchers || []).filter((watcher) => watcher.user_email !== deletedWatcher.user_email)
: existingData?.job_watchers || [];
cache.writeQuery({
query: GET_JOB_WATCHERS,
variables: { jobid },
data: {
...existingData,
job_watchers: updatedWatchers
}
});
}
});
const handleToggleSelf = useCallback(async () => {
if (adding || removing) return;
if (isWatching) {
await removeWatcher({ variables: { jobid, userEmail } });
} else {
await addWatcher({ variables: { jobid, userEmail } });
}
}, [isWatching, addWatcher, removeWatcher, jobid, userEmail, adding, removing]);
const handleRemoveWatcher = useCallback(
async (email) => {
if (removing) return;
await removeWatcher({ variables: { jobid, userEmail: email } });
},
[removeWatcher, jobid, removing]
);
const handleWatcherSelect = useCallback(
async (selectedUser) => {
if (adding || removing) return;
const employee = bodyshop.employees.find((e) => e.id === selectedUser);
if (!employee) return;
const email = employee.user_email;
const isAlreadyWatching = jobWatchers.some((w) => w.user_email === email);
if (isAlreadyWatching) {
await handleRemoveWatcher(email);
} else {
await addWatcher({ variables: { jobid, userEmail: email } });
}
setSelectedWatcher(null);
},
[jobWatchers, addWatcher, handleRemoveWatcher, jobid, bodyshop, adding, removing]
);
const handleTeamSelect = useCallback(
async (team) => {
if (adding) return;
const selectedTeamMembers = JSON.parse(team);
const newWatchers = selectedTeamMembers.filter(
(email) => !jobWatchers.some((watcher) => watcher.user_email === email)
);
if (newWatchers.length === 0) {
console.warn("All selected team members are already watchers.");
setSelectedTeam(null);
return;
}
await Promise.all(newWatchers.map((email) => addWatcher({ variables: { jobid, userEmail: email } })));
},
[jobWatchers, addWatcher, jobid, adding]
);
return (
<JobWatcherToggleComponent
jobWatchers={jobWatchers}
isWatching={isWatching}
watcherLoading={watcherLoading}
adding={adding}
removing={removing}
open={open}
setOpen={setOpen}
selectedWatcher={selectedWatcher}
setSelectedWatcher={setSelectedWatcher}
selectedTeam={selectedTeam}
setSelectedTeam={setSelectedTeam}
bodyshop={bodyshop}
Enhanced_Payroll={Enhanced_Payroll}
handleToggleSelf={handleToggleSelf}
handleRemoveWatcher={handleRemoveWatcher}
handleWatcherSelect={handleWatcherSelect}
handleTeamSelect={handleTeamSelect}
currentUser={currentUser}
/>
);
}
export default connect(mapStateToProps)(JobWatcherToggleContainer);

View File

@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { auth, logImEXEvent } from "../../firebase/firebase.utils";
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
import { GET_CURRENT_QUESTIONSET_ID, INSERT_CSI } from "../../graphql/csi.queries";

View File

@@ -1,94 +1,122 @@
// notification-center.component.jsx
import React from "react";
import { Virtuoso } from "react-virtuoso";
import { Button, Checkbox, List, Badge, Typography, Alert } from "antd";
import { Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd";
import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import "./notification-center.styles.scss";
import { Link } from "react-router-dom";
import day from "../../utils/day.js";
import { forwardRef, useRef, useEffect } from "react";
import { DateTimeFormat } from "../../utils/DateFormatter.jsx";
const { Text, Title } = Typography;
const NotificationCenterComponent = ({
visible,
onClose,
notifications,
loading,
error,
showUnreadOnly,
toggleUnreadOnly,
markAllRead,
loadMore,
onNotificationClick
}) => {
const { t } = useTranslation();
/**
* Notification Center Component
* @type {React.ForwardRefExoticComponent<React.PropsWithoutRef<{readonly visible?: *, readonly onClose?: *, readonly notifications?: *, readonly loading?: *, readonly showUnreadOnly?: *, readonly toggleUnreadOnly?: *, readonly markAllRead?: *, readonly loadMore?: *, readonly onNotificationClick?: *, readonly unreadCount?: *}> & React.RefAttributes<unknown>>}
*/
const NotificationCenterComponent = forwardRef(
(
{
visible,
onClose,
notifications,
loading,
showUnreadOnly,
toggleUnreadOnly,
markAllRead,
loadMore,
onNotificationClick,
unreadCount
},
ref
) => {
const { t } = useTranslation();
const navigate = useNavigate();
const virtuosoRef = useRef(null);
const renderNotification = (index, notification) => {
return (
<List.Item
key={`${notification.id}-${index}`}
className={notification.read ? "notification-read" : "notification-unread"}
onClick={() => !notification.read && onNotificationClick(notification.id)}
>
<Badge dot={!notification.read}>
<div>
<Title
level={5}
style={{
margin: "0 0 8px 0",
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}}
>
<Link
to={`/manage/jobs/${notification.jobid}`}
target="_blank"
onClick={(e) => {
e.stopPropagation(); // Prevent List.Item click handler from firing
!notification.read && onNotificationClick(notification.id); // Mark as read when link clicked
}}
>
RO #{notification.roNumber}
</Link>
<Text type="secondary">{new Date(notification.created_at).toLocaleString()}</Text>
</Title>
<Text strong={!notification.read}>
<ul>
{notification.scenarioText.map((text, idx) => (
<li key={`${notification.id}-${idx}`}>{text}</li>
))}
</ul>
</Text>
</div>
</Badge>
</List.Item>
);
};
// Scroll to top when showUnreadOnly changes
useEffect(() => {
if (virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({ index: 0, behavior: "smooth" });
}
}, [showUnreadOnly]);
return (
<div className={`notification-center ${visible ? "visible" : ""}`}>
<div className="notification-header">
<h3>{t("notifications.labels.notification-center")}</h3>
<div className="notification-controls">
<Checkbox checked={showUnreadOnly} onChange={(e) => toggleUnreadOnly(e.target.checked)}>
{t("notifications.labels.show-unread-only")}
</Checkbox>
<Button type="link" onClick={markAllRead} disabled={!notifications.some((n) => !n.read)}>
{t("notifications.labels.mark-all-read")}
</Button>
const renderNotification = (index, notification) => {
const handleClick = () => {
if (!notification.read) {
onNotificationClick(notification.id);
}
navigate(`/manage/jobs/${notification.jobid}`);
};
return (
<div
key={`${notification.id}-${index}`}
className={`notification-item ${notification.read ? "notification-read" : "notification-unread"}`}
onClick={handleClick}
>
<Badge dot={!notification.read}>
<div className="notification-content">
<Title level={5} className="notification-title">
<span className="ro-number">
{t("notifications.labels.ro-number", { ro_number: notification.roNumber || t("general.labels.na") })}
</span>
<Text type="secondary" className="relative-time" title={DateTimeFormat(notification.created_at)}>
{day(notification.created_at).fromNow()}
</Text>
</Title>
<Text strong={!notification.read} className="notification-body">
<ul>
{notification.scenarioText.map((text, idx) => (
<li key={`${notification.id}-${idx}`}>{text}</li>
))}
</ul>
</Text>
</div>
</Badge>
</div>
);
};
return (
<div className={`notification-center ${visible ? "visible" : ""}`} ref={ref}>
<div className="notification-header">
<Space direction="horizontal">
<h3>{t("notifications.labels.notification-center")}</h3>
{loading && <Spin spinning={loading} size="small"></Spin>}
</Space>
<div className="notification-controls">
<Tooltip title={t("notifications.labels.show-unread-only")}>
<Space size={4} align="center" className="notification-toggle">
{showUnreadOnly ? (
<EyeFilled className="notification-toggle-icon" />
) : (
<EyeOutlined className="notification-toggle-icon" />
)}
<Switch checked={showUnreadOnly} onChange={(checked) => toggleUnreadOnly(checked)} size="small" />
</Space>
</Tooltip>
<Tooltip title={t("notifications.labels.mark-all-read")}>
<Button
type="link"
icon={!unreadCount ? <CheckCircleFilled /> : <CheckCircleOutlined />}
onClick={markAllRead}
disabled={!unreadCount}
/>
</Tooltip>
</div>
</div>
<Virtuoso
ref={virtuosoRef}
style={{ height: "400px", width: "100%" }}
data={notifications}
totalCount={notifications.length}
endReached={loadMore}
itemContent={renderNotification}
/>
</div>
{error && <Alert message="Error" description={error} type="error" closable onClose={() => onClose()} />}
<Virtuoso
style={{ height: "400px", width: "100%" }}
data={notifications}
totalCount={notifications.length}
endReached={loadMore}
itemContent={renderNotification}
/>
{loading && !error && <div>{t("notifications.labels.loading")}</div>}
</div>
);
};
);
}
);
export default NotificationCenterComponent;

View File

@@ -1,17 +1,31 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@apollo/client";
import { connect } from "react-redux";
import NotificationCenterComponent from "./notification-center.component";
import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries";
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import day from "../../utils/day.js";
export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
// This will be used to poll for notifications when the socket is disconnected
const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;
/**
* Notification Center Container
* @param visible
* @param onClose
* @param bodyshop
* @param unreadCount
* @returns {JSX.Element}
* @constructor
*/
const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }) => {
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
const [notifications, setNotifications] = useState([]);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket();
const notificationRef = useRef(null);
const userAssociationId = bodyshop?.associations?.[0]?.id;
@@ -26,8 +40,7 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
const {
data,
fetchMore,
loading,
error: queryError,
loading: queryLoading,
refetch
} = useQuery(GET_NOTIFICATIONS, {
variables: {
@@ -37,15 +50,26 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
},
fetchPolicy: "cache-and-network",
notifyOnNetworkStatusChange: true,
pollInterval: isConnected ? 0 : 30000,
pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
skip: !userAssociationId,
onError: (err) => {
setError(err.message);
console.error("GET_NOTIFICATIONS error:", err);
setTimeout(() => refetch(), 2000);
console.error(`Error polling Notifications: ${err?.message || ""}`);
setTimeout(() => refetch(), day.duration(2, "seconds").asMilliseconds());
}
});
useEffect(() => {
const handleClickOutside = (event) => {
// Prevent open + close behavior from the header
if (event.target.closest("#header-notifications")) return;
if (visible && notificationRef.current && !notificationRef.current.contains(event.target)) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [visible, onClose]);
useEffect(() => {
if (data?.notifications) {
const processedNotifications = data.notifications
@@ -77,18 +101,12 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
})
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
setNotifications(processedNotifications);
setError(null);
}
}, [data]);
useEffect(() => {
if (queryError) {
setError(queryError.message);
}
}, [queryError]);
const loadMore = useCallback(() => {
if (!loading && data?.notifications.length) {
if (!queryLoading && data?.notifications.length) {
setIsLoading(true); // Show spinner during fetchMore
fetchMore({
variables: { offset: data.notifications.length, where: whereClause },
updateQuery: (prev, { fetchMoreResult }) => {
@@ -97,18 +115,20 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
notifications: [...prev.notifications, ...fetchMoreResult.notifications]
};
}
}).catch((err) => {
setError(err.message);
console.error("Fetch more error:", err);
});
})
.catch((err) => {
console.error("Fetch more error:", err);
})
.finally(() => setIsLoading(false)); // Hide spinner when done
}
}, [data?.notifications?.length, fetchMore, loading, whereClause]);
}, [data?.notifications?.length, fetchMore, queryLoading, whereClause]);
const handleToggleUnreadOnly = (value) => {
setShowUnreadOnly(value);
};
const handleMarkAllRead = useCallback(() => {
setIsLoading(true);
markAllNotificationsRead()
.then(() => {
const timestamp = new Date().toISOString();
@@ -121,53 +141,59 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
}
: notif
);
return [...updatedNotifications];
// Filter out read notifications if in unread only mode
return showUnreadOnly ? updatedNotifications.filter((notif) => !notif.read) : updatedNotifications;
});
})
.catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`));
}, [markAllNotificationsRead, userAssociationId]);
.catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`))
.finally(() => setIsLoading(false));
}, [markAllNotificationsRead, userAssociationId, showUnreadOnly]);
const handleNotificationClick = useCallback(
(notificationId) => {
markNotificationRead({
variables: { id: notificationId }
})
setIsLoading(true);
markNotificationRead({ variables: { id: notificationId } })
.then(() => {
const timestamp = new Date().toISOString();
setNotifications((prev) => {
return prev.map((notif) =>
const updatedNotifications = prev.map((notif) =>
notif.id === notificationId && !notif.read ? { ...notif, read: timestamp } : notif
);
// Filter out the read notification if in unread only mode
return showUnreadOnly ? updatedNotifications.filter((notif) => !notif.read) : updatedNotifications;
});
})
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`));
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`))
.finally(() => setIsLoading(false));
},
[markNotificationRead]
[markNotificationRead, showUnreadOnly]
);
useEffect(() => {
if (visible && !isConnected) {
refetch().catch(
(err) => `Something went wrong re-fetching notifications in the notification-center: ${err?.message || ""}`
);
setIsLoading(true);
refetch()
.catch((err) => console.error(`Error re-fetching notifications: ${err?.message || ""}`))
.finally(() => setIsLoading(false));
}
}, [visible, isConnected, refetch]);
return (
<NotificationCenterComponent
ref={notificationRef}
visible={visible}
onClose={onClose}
notifications={notifications}
loading={loading}
error={error}
loading={isLoading}
showUnreadOnly={showUnreadOnly}
toggleUnreadOnly={handleToggleUnreadOnly}
markAllRead={handleMarkAllRead}
loadMore={loadMore}
onNotificationClick={handleNotificationClick}
unreadCount={unreadCount}
/>
);
}
};
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -2,112 +2,174 @@
position: absolute;
top: 64px;
right: 0;
//width: 600px;
background: #fff; /* White background, Ants default */
color: rgba(0, 0, 0, 0.85); /* Primary text color in Ant 5 */
border: 1px solid #d9d9d9; /* Neutral gray border */
border-radius: 6px; /* Slightly larger radius per Ant 5 */
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06); /* Subtle Ant 5 shadow */
width: 400px;
max-width: 400px;
background: #fff;
color: rgba(0, 0, 0, 0.85);
border: 1px solid #d9d9d9;
border-radius: 6px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06);
z-index: 1000;
display: none;
overflow-x: hidden;
&.visible {
display: block;
}
.notification-header {
padding: 16px;
border-bottom: 1px solid #f0f0f0; /* Light gray border from Ant 5 */
padding: 4px 16px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
background: #fafafa; /* Light gray background for header */
background: #fafafa;
h3 {
margin: 0;
font-size: 16px;
color: rgba(0, 0, 0, 0.85); /* Primary text color */
font-size: 14px;
color: rgba(0, 0, 0, 0.85);
}
.notification-controls {
display: flex;
align-items: center;
gap: 16px;
gap: 8px;
.ant-checkbox-wrapper {
color: rgba(0, 0, 0, 0.85); /* Match Ants text color */
// Styles for the eye icon and switch (custom classes)
.notification-toggle {
align-items: center; // Ensure vertical alignment
}
.ant-btn-link {
color: #1677ff; /* Ant 5 primary blue */
&:hover {
color: #69b1ff; /* Lighter blue on hover */
.notification-toggle-icon {
font-size: 14px;
color: #1677ff;
vertical-align: middle;
}
.ant-switch {
&.ant-switch-small {
min-width: 28px;
height: 16px;
line-height: 16px;
.ant-switch-handle {
width: 12px;
height: 12px;
}
&.ant-switch-checked {
background-color: #1677ff;
.ant-switch-handle {
left: calc(100% - 14px);
}
}
}
}
// Styles for the "Mark All Read" button (restore original link button style)
.ant-btn-link {
padding: 0;
color: #1677ff;
&:hover {
color: #69b1ff;
}
&:disabled {
color: rgba(0, 0, 0, 0.25); /* Disabled text color from Ant 5 */
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
}
&.active {
color: #0958d9;
}
}
}
}
.notification-read {
background: #fff; /* White background for read items */
color: rgba(0, 0, 0, 0.65); /* Secondary text color */
background: #fff;
color: rgba(0, 0, 0, 0.65);
}
.notification-unread {
background: #f5f5f5; /* Very light gray for unread items */
color: rgba(0, 0, 0, 0.85); /* Primary text color */
background: #f5f5f5;
color: rgba(0, 0, 0, 0.85);
}
.ant-list {
overflow: auto; /* Match Virtuosos default scrolling behavior */
max-height: 100%; /* Allow full height, let Virtuoso handle virtualization */
}
.notification-item {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
display: block;
overflow: visible;
width: 100%;
box-sizing: border-box;
cursor: pointer;
.ant-list-item {
padding: 2px 16px;
border-bottom: 1px solid #f0f0f0; /* Light gray border */
display: block; /* Ensure visibility */
overflow: visible; /* Prevent clipping within items */
min-height: 80px; /* Minimum height for multi-line content */
.ant-typography {
color: inherit; /* Inherit from parent (read/unread) */
&:hover {
background: #fafafa;
}
.ant-typography-secondary {
font-size: 12px;
color: rgba(0, 0, 0, 0.45); /* Ant 5 secondary text color */
.notification-content {
width: 100%;
}
.ant-badge-dot {
background: #ff4d4f; /* Keep red dot for unread, consistent with Ant */
}
ul {
.notification-title {
margin: 0;
padding-left: 20px; /* Standard list padding */
list-style-type: disc; /* Ensure bullet points */
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
box-sizing: border-box;
.ro-number {
margin: 0;
color: #1677ff;
flex-shrink: 0;
white-space: nowrap;
}
.relative-time {
margin: 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
white-space: nowrap;
flex-shrink: 0;
margin-left: auto;
}
}
li {
margin-bottom: 4px; /* Space between list items */
.notification-body {
margin-top: 4px;
.ant-typography {
color: inherit;
}
ul {
margin: 0;
padding: 0;
}
li {
margin-bottom: 2px;
}
}
}
.ant-badge {
width: 100%;
}
.ant-alert {
margin: 8px;
background: #fff1f0; /* Light red background for error per Ant 5 */
background: #fff1f0;
color: rgba(0, 0, 0, 0.85);
border: 1px solid #ffa39e; /* Light red border */
border: 1px solid #ffa39e;
.ant-alert-message {
color: #ff4d4f; /* Red text for message */
}
.ant-alert-description {
color: rgba(0, 0, 0, 0.65); /* Slightly muted description */
color: #ff4d4f;
}
}
}

View File

@@ -0,0 +1,56 @@
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
import { Checkbox, Form } from "antd";
import { useTranslation } from "react-i18next";
import PropTypes from "prop-types";
/**
* ColumnHeaderCheckbox
* @param channel
* @param form
* @param disabled
* @param onHeaderChange
* @returns {JSX.Element}
* @constructor
*/
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => {
const { t } = useTranslation();
// Subscribe to all form values so that this component re-renders on changes.
const formValues = Form.useWatch([], form) || {};
// Determine if all scenarios for this channel are checked.
const allChecked =
notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]);
const onChange = (e) => {
const checked = e.target.checked;
// Get current form values.
const currentValues = form.getFieldsValue();
// Update each scenario for this channel.
const newValues = { ...currentValues };
notificationScenarios.forEach((scenario) => {
newValues[scenario] = { ...newValues[scenario], [channel]: checked };
});
// Update form values.
form.setFieldsValue(newValues);
// Manually mark the form as dirty.
if (onHeaderChange) {
onHeaderChange();
}
};
return (
<Checkbox onChange={onChange} checked={allChecked} disabled={disabled}>
{t(`notifications.channels.${channel}`)}
</Checkbox>
);
};
ColumnHeaderCheckbox.propTypes = {
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
form: PropTypes.object.isRequired,
disabled: PropTypes.bool,
onHeaderChange: PropTypes.func
};
export default ColumnHeaderCheckbox;

View File

@@ -1,8 +1,7 @@
import { useMutation, useQuery } from "@apollo/client";
import { useEffect, useState } from "react";
import { Button, Card, Checkbox, Form, Table } from "antd";
import { Button, Card, Checkbox, Form, Space, Table } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectCurrentUser } from "../../redux/user/user.selectors";
@@ -11,52 +10,21 @@ import { QUERY_NOTIFICATION_SETTINGS, UPDATE_NOTIFICATION_SETTINGS } from "../..
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
import PropTypes from "prop-types";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => {
const { t } = useTranslation();
// Subscribe to all form values so that this component re-renders on changes.
const formValues = Form.useWatch([], form) || {};
// Determine if all scenarios for this channel are checked.
const allChecked =
notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]);
const onChange = (e) => {
const checked = e.target.checked;
// Get current form values.
const currentValues = form.getFieldsValue();
// Update each scenario for this channel.
const newValues = { ...currentValues };
notificationScenarios.forEach((scenario) => {
newValues[scenario] = { ...newValues[scenario], [channel]: checked };
});
// Update form values.
form.setFieldsValue(newValues);
// Manually mark the form as dirty.
if (onHeaderChange) {
onHeaderChange();
}
};
return (
<Checkbox onChange={onChange} checked={allChecked} disabled={disabled}>
{t(`notifications.channels.${channel}`)}
</Checkbox>
);
};
ColumnHeaderCheckbox.propTypes = {
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
form: PropTypes.object.isRequired,
disabled: PropTypes.bool,
onHeaderChange: PropTypes.func
};
function NotificationSettingsForm({ currentUser }) {
/**
* Notifications Settings Form
* @param currentUser
* @returns {JSX.Element}
* @constructor
*/
const NotificationSettingsForm = ({ currentUser }) => {
const { t } = useTranslation();
const [form] = Form.useForm();
const [initialValues, setInitialValues] = useState({});
const [isDirty, setIsDirty] = useState(false);
const notification = useNotification();
// Fetch notification settings.
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
@@ -88,9 +56,14 @@ function NotificationSettingsForm({ currentUser }) {
if (data?.associations?.length > 0) {
const userId = data.associations[0].id;
// Save the updated notification settings.
await updateNotificationSettings({ variables: { id: userId, ns: values } });
setInitialValues(values);
setIsDirty(false);
const result = await updateNotificationSettings({ variables: { id: userId, ns: values } });
if (!result?.errors) {
notification.success({ message: t("notifications.labels.notification-settings-success") });
setInitialValues(values);
setIsDirty(false);
} else {
notification.error({ message: t("notifications.labels.notification-settings-failure") });
}
}
};
@@ -165,21 +138,22 @@ function NotificationSettingsForm({ currentUser }) {
<Card
title={t("notifications.labels.notificationscenarios")}
extra={
<>
<Button type="default" onClick={handleReset} disabled={!isDirty} style={{ marginRight: 8 }}>
<Space>
<Button type="default" onClick={handleReset} disabled={!isDirty}>
{t("general.actions.clear")}
</Button>
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={saving}>
{t("notifications.labels.save")}
</Button>
</>
</Space>
}
>
<Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" />
</Card>
</Form>
);
}
};
NotificationSettingsForm.propTypes = {
currentUser: PropTypes.shape({

View File

@@ -1,17 +1,16 @@
import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import PartsOrderModalPriceChange from "./parts-order-modal-price-change.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -33,7 +32,7 @@ export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState,
});
const { t } = useTranslation();
const handleClick = ({ item, key, keyPath }) => {
const handleClick = ({ item }) => {
form.setFieldsValue({ comments: item.props.value });
};
@@ -98,17 +97,18 @@ export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState,
<Checkbox />
</Form.Item>
)}
<Form.Item name="order_type" initialValue="parts_order" label={t("parts_orders.labels.order_type")}>
<Radio.Group disabled={sendType === "oec"}>
<Radio value={"parts_order"}>{t("parts_orders.labels.parts_order")}</Radio>
<Radio value={"sublet"}>{t("parts_orders.labels.sublet_order")}</Radio>
</Radio.Group>
</Form.Item>
{!isReturn && (
<Form.Item name="order_type" initialValue="parts_order" label={t("parts_orders.labels.order_type")}>
<Radio.Group disabled={sendType === "oec"}>
<Radio value={"parts_order"}>{t("parts_orders.labels.parts_order")}</Radio>
<Radio value={"sublet"}>{t("parts_orders.labels.sublet_order")}</Radio>
</Radio.Group>
</Form.Item>
)}
</LayoutFormRow>
<Divider orientation="left">{t("parts_orders.labels.inthisorder")}</Divider>
<Form.List name={["parts_order_lines", "data"]}>
{(fields, { add, remove, move }) => {
{(fields, { remove, move }) => {
return (
<div>
{fields.map((field, index) => (

View File

@@ -10,7 +10,7 @@ import { createStructuredSelector } from "reselect";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,

View File

@@ -12,7 +12,7 @@ import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,

View File

@@ -27,6 +27,8 @@ import ScoreboardAddButton from "../job-scoreboard-add-button/job-scoreboard-add
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import ProductionRemoveButton from "../production-remove-button/production-remove-button.component";
import JobWatcherToggleContainer from "../job-watcher-toggle/job-watcher-toggle.container.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -41,6 +43,7 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
const search = queryString.parse(useLocation().search);
const history = useNavigate();
const { selected } = search;
const { scenarioNotificationsOn } = useSocket();
const { t } = useTranslation();
const theJob = jobs.find((j) => j.id === selected) || {};
@@ -60,7 +63,12 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
<Drawer
title={
<PageHeader
title={theJob.ro_number}
title={
<Space>
{!technician && scenarioNotificationsOn && <JobWatcherToggleContainer job={theJob} />}
{theJob.ro_number}
</Space>
}
extra={
<Space wrap>
{!technician ? <ProductionRemoveButton jobId={theJob.id} /> : null}

View File

@@ -10,7 +10,7 @@ import {
import ProductionListTable from "./production-list-table.component";
import _ from "lodash";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
const client = useApolloClient();

View File

@@ -39,7 +39,7 @@ export default function ProductionRemoveButton({ jobId }) {
};
return (
<Button loading={loading} onClick={handleRemoveFromProd} type={"danger"}>
<Button loading={loading} onClick={handleRemoveFromProd} type="default" danger>
{t("production.actions.remove")}
</Button>
);

View File

@@ -8,7 +8,8 @@ import { selectCurrentUser } from "../../redux/user/user.selectors";
import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import NotificationSettingsForm from "./notification-settings.component.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import NotificationSettingsForm from "../notification-settings/notification-settings-form.component.jsx";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser
@@ -22,6 +23,7 @@ export default connect(
)(function ProfileMyComponent({ currentUser, updateUserDetails }) {
const { t } = useTranslation();
const notification = useNotification();
const { scenarioNotificationsOn } = useSocket();
const handleFinish = (values) => {
logImEXEvent("profile_update");
@@ -117,9 +119,11 @@ export default connect(
</Card>
</Form>
</Col>
<Col span={24}>
<NotificationSettingsForm />
</Col>
{scenarioNotificationsOn && (
<Col span={24}>
<NotificationSettingsForm />
</Col>
)}
</>
);
});

View File

@@ -1,7 +1,7 @@
import { AlertOutlined } from "@ant-design/icons";
import { Alert, Button, Col, Row, Space } from "antd";
import i18n from "i18next";
import React, { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -81,8 +81,7 @@ export function UpdateAlert({ updateAvailable }) {
imex: "$t(titles.imexonline)",
rome: "$t(titles.romeonline)"
})
}),
placement: "bottomRight"
})
});
}
if (needRefresh && timerStarted && timeLeft <= 0) {

View File

@@ -1,5 +1,4 @@
// NotificationProvider.jsx
import React, { createContext, useContext } from "react";
import { createContext, useContext } from "react";
import { notification } from "antd";
/**
@@ -22,7 +21,11 @@ export const useNotification = () => {
* - Provide `api` via the NotificationContext.
*/
export const NotificationProvider = ({ children }) => {
const [api, contextHolder] = notification.useNotification();
const [api, contextHolder] = notification.useNotification({
placement: "bottomRight",
bottom: 70,
showProgress: true
});
return (
<NotificationContext.Provider value={api}>

View File

@@ -1,384 +0,0 @@
import { createContext, useContext, useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client";
import { auth } from "../../firebase/firebase.utils";
import { store } from "../../redux/store";
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
import client from "../../utils/GraphQLClient";
import { useNotification } from "../Notifications/notificationContext.jsx";
import {
GET_NOTIFICATIONS,
GET_UNREAD_COUNT,
MARK_ALL_NOTIFICATIONS_READ,
MARK_NOTIFICATION_READ
} from "../../graphql/notifications.queries.js";
import { useMutation } from "@apollo/client";
const SocketContext = createContext(null);
// This is how many notifications the database will populate on load, and the increment for load more
export const INITIAL_NOTIFICATIONS = 10;
export const SCENARIO_NOTIFICATION_LOCATION = "bottomRight";
export const SCENARIO_NOTIFICATION_DURATION = 15; // Seconds
export const SocketProvider = ({ children, bodyshop, navigate }) => {
const socketRef = useRef(null);
const [clientId, setClientId] = useState(null);
const [isConnected, setIsConnected] = useState(false);
const notification = useNotification();
const userAssociationId = bodyshop?.associations?.[0]?.id;
const [markNotificationRead] = useMutation(MARK_NOTIFICATION_READ, {
update: (cache, { data: { update_notifications } }) => {
const timestamp = new Date().toISOString();
const updatedNotification = update_notifications.returning[0];
// Update the notifications list
cache.modify({
fields: {
notifications(existing = [], { readField }) {
return existing.map((notif) => {
if (readField("id", notif) === updatedNotification.id) {
return { ...notif, read: timestamp };
}
return notif;
});
}
}
});
// Update the unread count in notifications_aggregate
const unreadCountQuery = cache.readQuery({
query: GET_UNREAD_COUNT,
variables: { associationid: userAssociationId }
});
if (unreadCountQuery?.notifications_aggregate?.aggregate?.count > 0) {
cache.writeQuery({
query: GET_UNREAD_COUNT,
variables: { associationid: userAssociationId },
data: {
notifications_aggregate: {
...unreadCountQuery.notifications_aggregate,
aggregate: {
...unreadCountQuery.notifications_aggregate.aggregate,
count: unreadCountQuery.notifications_aggregate.aggregate.count - 1
}
}
}
});
}
},
onError: (err) => {
console.error("MARK_NOTIFICATION_READ error in SocketProvider:", err);
}
});
const [markAllNotificationsRead] = useMutation(MARK_ALL_NOTIFICATIONS_READ, {
variables: { associationid: userAssociationId },
update: (cache) => {
const timestamp = new Date().toISOString();
cache.modify({
fields: {
notifications(existing = [], { readField }) {
return existing.map((notif) => {
if (readField("read", notif) === null && readField("associationid", notif) === userAssociationId) {
return { ...notif, read: timestamp };
}
return notif;
});
},
notifications_aggregate() {
return { aggregate: { count: 0, __typename: "notifications_aggregate_fields" } };
}
}
});
const baseWhereClause = { associationid: { _eq: userAssociationId } };
const cachedNotifications = cache.readQuery({
query: GET_NOTIFICATIONS,
variables: {
limit: INITIAL_NOTIFICATIONS,
offset: 0,
where: baseWhereClause
}
});
if (cachedNotifications?.notifications) {
cache.writeQuery({
query: GET_NOTIFICATIONS,
variables: {
limit: INITIAL_NOTIFICATIONS,
offset: 0,
where: baseWhereClause
},
data: {
notifications: cachedNotifications.notifications.map((notif) =>
notif.read === null ? { ...notif, read: timestamp } : notif
)
}
});
}
},
onError: (err) => {
console.error("MARK_ALL_NOTIFICATIONS_READ error in SocketProvider:", err);
}
});
useEffect(() => {
const initializeSocket = async (token) => {
if (!bodyshop || !bodyshop.id || socketRef.current) return;
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
const socketInstance = SocketIO(endpoint, {
path: "/wss",
withCredentials: true,
auth: { token, bodyshopId: bodyshop.id },
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000
});
socketRef.current = socketInstance;
const handleBodyshopMessage = (message) => {
if (!message || !message.type) return;
switch (message.type) {
case "alert-update":
store.dispatch(addAlerts(message.payload));
break;
default:
break;
}
};
const handleConnect = () => {
socketInstance.emit("join-bodyshop-room", bodyshop.id);
setClientId(socketInstance.id);
setIsConnected(true);
store.dispatch(setWssStatus("connected"));
};
const handleReconnect = () => {
setIsConnected(true);
store.dispatch(setWssStatus("connected"));
};
const handleConnectionError = (err) => {
console.error("Socket connection error:", err);
setIsConnected(false);
if (err.message.includes("auth/id-token-expired")) {
console.warn("Token expired, refreshing...");
auth.currentUser?.getIdToken(true).then((newToken) => {
socketInstance.auth = { token: newToken };
socketInstance.connect();
});
} else {
store.dispatch(setWssStatus("error"));
}
};
const handleDisconnect = (reason) => {
console.warn("Socket disconnected:", reason);
setIsConnected(false);
store.dispatch(setWssStatus("disconnected"));
if (!socketInstance.connected && reason !== "io server disconnect") {
setTimeout(() => {
if (socketInstance.disconnected) {
console.log("Manually triggering reconnection...");
socketInstance.connect();
}
}, 2000);
}
};
const handleNotification = (data) => {
const { jobId, jobRoNumber, notificationId, associationId, notifications } = data;
if (associationId !== userAssociationId) return;
const newNotification = {
__typename: "notifications",
id: notificationId,
jobid: jobId,
associationid: associationId,
scenario_text: JSON.stringify(notifications.map((notif) => notif.body)),
fcm_text: notifications.map((notif) => notif.body).join(". ") + ".",
scenario_meta: JSON.stringify(notifications.map((notif) => notif.variables || {})),
created_at: new Date(notifications[0].timestamp).toISOString(),
read: null,
job: {
ro_number: jobRoNumber
}
};
const baseVariables = {
limit: INITIAL_NOTIFICATIONS,
offset: 0,
where: { associationid: { _eq: userAssociationId } }
};
try {
const existingNotifications =
client.cache.readQuery({
query: GET_NOTIFICATIONS,
variables: baseVariables
})?.notifications || [];
if (existingNotifications.some((n) => n.id === newNotification.id)) {
return;
}
client.cache.writeQuery({
query: GET_NOTIFICATIONS,
variables: baseVariables,
data: {
notifications: [newNotification, ...existingNotifications].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
)
},
broadcast: true
});
const unreadVariables = {
...baseVariables,
where: { ...baseVariables.where, read: { _is_null: true } }
};
const unreadNotifications =
client.cache.readQuery({
query: GET_NOTIFICATIONS,
variables: unreadVariables
})?.notifications || [];
if (newNotification.read === null && !unreadNotifications.some((n) => n.id === newNotification.id)) {
client.cache.writeQuery({
query: GET_NOTIFICATIONS,
variables: unreadVariables,
data: {
notifications: [newNotification, ...unreadNotifications].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
)
},
broadcast: true
});
}
client.cache.modify({
id: "ROOT_QUERY",
fields: {
notifications_aggregate(existing = { aggregate: { count: 0 } }) {
const isUnread = newNotification.read === null;
const countChange = isUnread ? 1 : 0;
return {
...existing,
aggregate: {
...existing.aggregate,
count: existing.aggregate.count + countChange
}
};
}
}
});
notification.info({
message: `Changes for ${jobRoNumber}:`,
description: (
<ul
className="notification-alert-unorderd-list"
onClick={() => {
markNotificationRead({ variables: { id: notificationId } })
.then(() => navigate(`/manage/jobs/${jobId}`))
.catch((e) => console.error(`Error marking notification read from info: ${e?.message || ""}`));
}}
style={{ cursor: "pointer" }}
>
{notifications.map((notif, index) => (
<li className="notification-alert-unorderd-list-item" key={index}>
{notif.body}
</li>
))}
</ul>
),
placement: SCENARIO_NOTIFICATION_LOCATION,
duration: SCENARIO_NOTIFICATION_DURATION
});
} catch (error) {
console.error(`Something went wrong handling a new notification: ${error?.message || ""}`);
}
};
socketInstance.on("connect", handleConnect);
socketInstance.on("reconnect", handleReconnect);
socketInstance.on("connect_error", handleConnectionError);
socketInstance.on("disconnect", handleDisconnect);
socketInstance.on("bodyshop-message", handleBodyshopMessage);
socketInstance.on("message", (message) => {
try {
if (typeof message === "string" && message.startsWith("42")) {
const parsedMessage = JSON.parse(message.slice(2));
const [event, data] = parsedMessage;
if (event === "notification") handleNotification(data);
} else if (Array.isArray(message)) {
const [event, data] = message;
if (event === "notification") handleNotification(data);
}
} catch (error) {
console.error("Error parsing socket message:", error);
}
});
socketInstance.on("notification", handleNotification);
};
const unsubscribe = auth.onIdTokenChanged(async (user) => {
if (user) {
const token = await user.getIdToken();
if (socketRef.current) {
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
} else {
initializeSocket(token).catch((err) =>
console.error(`Something went wrong Initializing Sockets: ${err?.message || ""}`)
);
}
} else {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setIsConnected(false);
}
}
});
return () => {
unsubscribe();
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setIsConnected(false);
}
};
}, [bodyshop, notification, userAssociationId, markNotificationRead, markAllNotificationsRead, navigate]);
return (
<SocketContext.Provider
value={{
socket: socketRef.current,
clientId,
isConnected,
markNotificationRead,
markAllNotificationsRead
}}
>
{children}
</SocketContext.Provider>
);
};
export const useSocket = () => {
const context = useContext(SocketContext);
if (!context) {
throw new Error("useSocket must be used within a SocketProvider");
}
return context;
};
export default SocketContext;

View File

@@ -0,0 +1,500 @@
import { createContext, useContext, useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client";
import { auth } from "../../firebase/firebase.utils";
import { store } from "../../redux/store";
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
import client from "../../utils/GraphQLClient";
import { useNotification } from "../Notifications/notificationContext.jsx";
import {
GET_NOTIFICATIONS,
GET_UNREAD_COUNT,
MARK_ALL_NOTIFICATIONS_READ,
MARK_NOTIFICATION_READ,
UPDATE_NOTIFICATIONS_READ_FRAGMENT
} from "../../graphql/notifications.queries.js";
import { useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
const SocketContext = createContext(null);
const INITIAL_NOTIFICATIONS = 10;
/**
* Socket Provider - Scenario Notifications / Web Socket related items
* @param children
* @param bodyshop
* @param navigate
* @param currentUser
* @returns {JSX.Element}
* @constructor
*/
const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
const socketRef = useRef(null);
const [clientId, setClientId] = useState(null);
const [isConnected, setIsConnected] = useState(false);
const notification = useNotification();
const userAssociationId = bodyshop?.associations?.[0]?.id;
const { t } = useTranslation();
const {
treatments: { Realtime_Notifications_UI }
} = useSplitTreatments({
attributes: {},
names: ["Realtime_Notifications_UI"],
splitKey: bodyshop?.imexshopid
});
const [markNotificationRead] = useMutation(MARK_NOTIFICATION_READ, {
update: (cache, { data: { update_notifications } }) => {
const timestamp = new Date().toISOString();
const updatedNotification = update_notifications.returning[0];
cache.modify({
fields: {
notifications(existing = [], { readField }) {
return existing.map((notif) =>
readField("id", notif) === updatedNotification.id
? {
...notif,
read: timestamp
}
: notif
);
}
}
});
const unreadCountQuery = cache.readQuery({
query: GET_UNREAD_COUNT,
variables: { associationid: userAssociationId }
});
if (unreadCountQuery?.notifications_aggregate?.aggregate?.count > 0) {
cache.writeQuery({
query: GET_UNREAD_COUNT,
variables: { associationid: userAssociationId },
data: {
notifications_aggregate: {
...unreadCountQuery.notifications_aggregate,
aggregate: {
...unreadCountQuery.notifications_aggregate.aggregate,
count: unreadCountQuery.notifications_aggregate.aggregate.count - 1
}
}
}
});
}
if (socketRef.current && isConnected) {
socketRef.current.emit("sync-notification-read", {
email: currentUser?.email,
bodyshopId: bodyshop.id,
notificationId: updatedNotification.id
});
}
},
onError: (err) =>
console.error("MARK_NOTIFICATION_READ error:", {
message: err?.message,
stack: err?.stack
})
});
const [markAllNotificationsRead] = useMutation(MARK_ALL_NOTIFICATIONS_READ, {
variables: { associationid: userAssociationId },
update: (cache) => {
const timestamp = new Date().toISOString();
cache.modify({
fields: {
notifications(existing = [], { readField }) {
return existing.map((notif) =>
readField("read", notif) === null && readField("associationid", notif) === userAssociationId
? { ...notif, read: timestamp }
: notif
);
},
notifications_aggregate() {
return { aggregate: { count: 0, __typename: "notifications_aggregate_fields" } };
}
}
});
const baseWhereClause = { associationid: { _eq: userAssociationId } };
const cachedNotifications = cache.readQuery({
query: GET_NOTIFICATIONS,
variables: { limit: INITIAL_NOTIFICATIONS, offset: 0, where: baseWhereClause }
});
if (cachedNotifications?.notifications) {
cache.writeQuery({
query: GET_NOTIFICATIONS,
variables: { limit: INITIAL_NOTIFICATIONS, offset: 0, where: baseWhereClause },
data: {
notifications: cachedNotifications.notifications.map((notif) =>
notif.read === null ? { ...notif, read: timestamp } : notif
)
}
});
}
if (socketRef.current && isConnected) {
socketRef.current.emit("sync-all-notifications-read", {
email: currentUser?.email,
bodyshopId: bodyshop.id
});
}
},
onError: (err) => console.error("MARK_ALL_NOTIFICATIONS_READ error:", err)
});
useEffect(() => {
const initializeSocket = async (token) => {
if (!bodyshop || !bodyshop.id || socketRef.current) return;
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
const socketInstance = SocketIO(endpoint, {
path: "/wss",
withCredentials: true,
auth: { token, bodyshopId: bodyshop.id },
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000
});
socketRef.current = socketInstance;
const handleBodyshopMessage = (message) => {
if (!message || !message.type) return;
switch (message.type) {
case "alert-update":
store.dispatch(addAlerts(message.payload));
break;
default:
break;
}
};
const handleConnect = () => {
socketInstance.emit("join-bodyshop-room", bodyshop.id);
setClientId(socketInstance.id);
setIsConnected(true);
store.dispatch(setWssStatus("connected"));
};
const handleReconnect = () => {
setIsConnected(true);
store.dispatch(setWssStatus("connected"));
};
const handleConnectionError = (err) => {
console.error("Socket connection error:", err);
setIsConnected(false);
if (err.message.includes("auth/id-token-expired")) {
console.warn("Token expired, refreshing...");
auth.currentUser?.getIdToken(true).then((newToken) => {
socketInstance.auth = { token: newToken };
socketInstance.connect();
});
} else {
store.dispatch(setWssStatus("error"));
}
};
const handleDisconnect = (reason) => {
console.warn("Socket disconnected:", reason);
setIsConnected(false);
store.dispatch(setWssStatus("disconnected"));
if (!socketInstance.connected && reason !== "io server disconnect") {
setTimeout(() => {
if (socketInstance.disconnected) {
console.log("Manually triggering reconnection...");
socketInstance.connect();
}
}, 2000);
}
};
const handleNotification = (data) => {
// Scenario Notifications have been disabled, bail.
if (Realtime_Notifications_UI?.treatment !== "on") {
return;
}
const { jobId, jobRoNumber, notificationId, associationId, notifications } = data;
if (associationId !== userAssociationId) return;
const newNotification = {
__typename: "notifications",
id: notificationId,
jobid: jobId,
associationid: associationId,
scenario_text: JSON.stringify(notifications.map((notif) => notif.body)),
fcm_text: notifications.map((notif) => notif.body).join(". ") + ".",
scenario_meta: JSON.stringify(notifications.map((notif) => notif.variables || {})),
created_at: new Date(notifications[0].timestamp).toISOString(),
read: null,
job: { ro_number: jobRoNumber }
};
const baseVariables = {
limit: INITIAL_NOTIFICATIONS,
offset: 0,
where: { associationid: { _eq: userAssociationId } }
};
try {
const existingNotifications =
client.cache.readQuery({
query: GET_NOTIFICATIONS,
variables: baseVariables
})?.notifications || [];
if (!existingNotifications.some((n) => n.id === newNotification.id)) {
client.cache.writeQuery({
query: GET_NOTIFICATIONS,
variables: baseVariables,
data: {
notifications: [newNotification, ...existingNotifications].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
)
},
broadcast: true
});
const unreadVariables = {
...baseVariables,
where: { ...baseVariables.where, read: { _is_null: true } }
};
const unreadNotifications =
client.cache.readQuery({
query: GET_NOTIFICATIONS,
variables: unreadVariables
})?.notifications || [];
if (newNotification.read === null && !unreadNotifications.some((n) => n.id === newNotification.id)) {
client.cache.writeQuery({
query: GET_NOTIFICATIONS,
variables: unreadVariables,
data: {
notifications: [newNotification, ...unreadNotifications].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
)
},
broadcast: true
});
}
client.cache.modify({
id: "ROOT_QUERY",
fields: {
notifications_aggregate(existing = { aggregate: { count: 0 } }) {
return {
...existing,
aggregate: {
...existing.aggregate,
count: existing.aggregate.count + (newNotification.read === null ? 1 : 0)
}
};
}
}
});
notification.info({
message: (
<div
onClick={() => {
markNotificationRead({ variables: { id: notificationId } })
.then(() => navigate(`/manage/jobs/${jobId}`))
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`));
}}
>
{t("notifications.labels.notification-popup-title", {
ro_number: jobRoNumber || t("general.labels.na")
})}
</div>
),
description: (
<ul
className="notification-alert-unordered-list"
onClick={() => {
markNotificationRead({ variables: { id: notificationId } })
.then(() => navigate(`/manage/jobs/${jobId}`))
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`));
}}
>
{notifications.map((notif, index) => (
<li className="notification-alert-unordered-list-item" key={index}>
{notif.body}
</li>
))}
</ul>
)
});
}
} catch (error) {
console.error(`Error handling new notification: ${error?.message || ""}`);
}
};
const handleSyncNotificationRead = ({ notificationId, timestamp }) => {
// Scenario Notifications have been disabled, bail.
if (Realtime_Notifications_UI?.treatment !== "on") {
return;
}
try {
const notificationRef = client.cache.identify({
__typename: "notifications",
id: notificationId
});
client.cache.writeFragment({
id: notificationRef,
fragment: UPDATE_NOTIFICATIONS_READ_FRAGMENT,
data: { read: timestamp }
});
const unreadCountData = client.cache.readQuery({
query: GET_UNREAD_COUNT,
variables: { associationid: userAssociationId }
});
if (unreadCountData?.notifications_aggregate?.aggregate?.count > 0) {
const newCount = Math.max(unreadCountData.notifications_aggregate.aggregate.count - 1, 0);
client.cache.writeQuery({
query: GET_UNREAD_COUNT,
variables: { associationid: userAssociationId },
data: {
notifications_aggregate: {
__typename: "notifications_aggregate",
aggregate: {
__typename: "notifications_aggregate_fields",
count: newCount
}
}
}
});
}
} catch (error) {
console.error("Error in handleSyncNotificationRead:", error);
}
};
const handleSyncAllNotificationsRead = ({ timestamp }) => {
// Scenario Notifications have been disabled, bail.
if (Realtime_Notifications_UI?.treatment !== "on") {
return;
}
try {
const queryVars = {
limit: INITIAL_NOTIFICATIONS,
offset: 0,
where: { associationid: { _eq: userAssociationId } }
};
const cachedData = client.cache.readQuery({
query: GET_NOTIFICATIONS,
variables: queryVars
});
if (cachedData?.notifications) {
cachedData.notifications.forEach((notif) => {
if (!notif.read) {
const notifRef = client.cache.identify({ __typename: "notifications", id: notif.id });
client.cache.writeFragment({
id: notifRef,
fragment: UPDATE_NOTIFICATIONS_READ_FRAGMENT,
data: { read: timestamp }
});
}
});
}
client.cache.writeQuery({
query: GET_UNREAD_COUNT,
variables: { associationid: userAssociationId },
data: {
notifications_aggregate: {
__typename: "notifications_aggregate",
aggregate: {
__typename: "notifications_aggregate_fields",
count: 0
}
}
}
});
} catch (error) {
console.error(`Error In HandleSyncAllNotificationsRead: ${error?.message || ""}`);
}
};
socketInstance.on("connect", handleConnect);
socketInstance.on("reconnect", handleReconnect);
socketInstance.on("connect_error", handleConnectionError);
socketInstance.on("disconnect", handleDisconnect);
socketInstance.on("bodyshop-message", handleBodyshopMessage);
socketInstance.on("notification", handleNotification);
socketInstance.on("sync-notification-read", handleSyncNotificationRead);
socketInstance.on("sync-all-notifications-read", handleSyncAllNotificationsRead);
};
const unsubscribe = auth.onIdTokenChanged(async (user) => {
if (user) {
const token = await user.getIdToken();
if (socketRef.current) {
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
} else {
initializeSocket(token).catch((err) =>
console.error(`Something went wrong Initializing Sockets: ${err?.message || ""}`)
);
}
} else {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setIsConnected(false);
}
}
});
return () => {
unsubscribe();
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setIsConnected(false);
}
};
}, [
bodyshop,
notification,
userAssociationId,
markNotificationRead,
markAllNotificationsRead,
navigate,
currentUser,
Realtime_Notifications_UI,
t
]);
return (
<SocketContext.Provider
value={{
socket: socketRef.current,
clientId,
isConnected,
markNotificationRead,
markAllNotificationsRead,
scenarioNotificationsOn: Realtime_Notifications_UI?.treatment === "on"
}}
>
{children}
</SocketContext.Provider>
);
};
const useSocket = () => {
const context = useContext(SocketContext);
// NOTE: Not sure if we absolutely require this, does cause slipups on dev env
if (!context) throw new Error("useSocket must be used within a SocketProvider");
return context;
};
export { SocketContext, SocketProvider, INITIAL_NOTIFICATIONS, useSocket };

View File

@@ -1,12 +1,10 @@
import { onError } from "@apollo/client/link/error";
//https://stackoverflow.com/questions/57163454/refreshing-a-token-with-apollo-client-firebase-auth
import * as Sentry from "@sentry/react";
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path }) => {
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`);
Sentry.captureException({ message, locations, path });
});
}
if (networkError) console.log(`[Network error]: ${JSON.stringify(networkError)}`);

View File

@@ -525,6 +525,7 @@ export const GET_JOB_BY_PK = gql`
iouparent
job_totals
job_watchers {
id
user_email
}
joblines(where: { removed: { _eq: false } }, order_by: { line_no: asc }) {
@@ -2594,6 +2595,10 @@ export const REMOVE_JOB_WATCHER = gql`
mutation REMOVE_JOB_WATCHER($jobid: uuid!, $userEmail: String!) {
delete_job_watchers(where: { jobid: { _eq: $jobid }, user_email: { _eq: $userEmail } }) {
affected_rows
returning {
id
user_email
}
}
}
`;

View File

@@ -12,6 +12,7 @@ export const GET_NOTIFICATIONS = gql`
created_at
read
job {
id
ro_number
}
}
@@ -49,3 +50,9 @@ export const MARK_NOTIFICATION_READ = gql`
}
}
`;
export const UPDATE_NOTIFICATIONS_READ_FRAGMENT = gql`
fragment UpdateNotificationRead on notifications {
read
}
`;

View File

@@ -1,10 +1,12 @@
import "./utils/sentry"; //Must be first.
import * as Sentry from "@sentry/react";
import { ConfigProvider } from "antd";
import Dinero from "dinero.js";
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import { createBrowserRouter, createRoutesFromElements, Route, RouterProvider } from "react-router-dom";
import { PersistGate } from "redux-persist/integration/react";
import { registerSW } from "virtual:pwa-register";
import AppContainer from "./App/App.container";
import LoadingSpinner from "./components/loading-spinner/loading-spinner.component";
import "./index.css";
@@ -12,56 +14,18 @@ import { persistor, store } from "./redux/store";
import reportWebVitals from "./reportWebVitals";
import "./translations/i18n";
import "./utils/CleanAxios";
import { ConfigProvider } from "antd";
import InstanceRenderManager from "./utils/instanceRenderMgr";
import { registerSW } from "virtual:pwa-register";
window.global ||= window;
registerSW({ immediate: true });
//import { BrowserTracing } from "@sentry/tracing";
//import "antd/dist/antd.css";
// import "antd/dist/antd.less";
// Dinero.defaultCurrency = "CAD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
if (import.meta.env.PROD) {
Sentry.init({
dsn: InstanceRenderManager({
imex: "https://fd7e89369b6b4bdc9c6c4c9f22fa4ee4@o492140.ingest.sentry.io/5651027",
rome: "https://a6acc91c073e414196014b8484627a61@o492140.ingest.sentry.io/4504561071161344"
}),
const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createBrowserRouter);
ignoreErrors: [
"ResizeObserver loop",
"ResizeObserver loop limit exceeded",
"Module specifier, 'fs' does not start",
"Module specifier, 'zlib' does not start with"
],
integrations: [
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: true
}),
Sentry.browserTracingIntegration()
],
tracePropagationTargets: [
"api.imex.online",
"api.test.imex.online",
"db.imex.online",
"api.romeonline.io",
"api.test.romeonline.io",
"db.romeonline.io"
],
tracesSampleRate: 1.0,
replaysOnErrorSampleRate: 1.0,
environment: import.meta.env.MODE
});
}
const router = createBrowserRouter(createRoutesFromElements(<Route path="*" element={<AppContainer />} />));
const router = sentryCreateBrowserRouter(createRoutesFromElements(<Route path="*" element={<AppContainer />} />));
if (import.meta.env.DEV) {
let styles =

View File

@@ -1,196 +0,0 @@
import React, { useCallback, useMemo, useState } from "react";
import { useMutation, useQuery } from "@apollo/client";
import { EyeFilled, EyeOutlined, UserOutlined } from "@ant-design/icons";
import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../graphql/jobs.queries.js";
import { Avatar, Button, Divider, List, Popover, Select, Tooltip, Typography } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
import EmployeeSearchSelectComponent from "../../components/employee-search-select/employee-search-select.component.jsx";
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component.jsx";
const { Text } = Typography;
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const JobWatcherToggle = ({ job, currentUser, bodyshop }) => {
const { t } = useTranslation();
const userEmail = currentUser.email;
const jobid = job.id;
const [open, setOpen] = useState(false);
const [selectedWatcher, setSelectedWatcher] = useState(null); // New state for selected value
const [selectedTeam, setSelectedTeam] = useState(null); // New state to track selected team
// Fetch current watchers
const { data: watcherData, loading: watcherLoading } = useQuery(GET_JOB_WATCHERS, { variables: { jobid } });
// Extract watchers list
const jobWatchers = useMemo(() => watcherData?.job_watchers || [], [watcherData]);
const isWatching = useMemo(() => jobWatchers.some((w) => w.user_email === userEmail), [jobWatchers, userEmail]);
// Add watcher mutation
const [addWatcher, { loading: adding }] = useMutation(ADD_JOB_WATCHER, {
refetchQueries: [{ query: GET_JOB_WATCHERS, variables: { jobid } }]
});
// Remove watcher mutation
const [removeWatcher, { loading: removing }] = useMutation(REMOVE_JOB_WATCHER, {
refetchQueries: [{ query: GET_JOB_WATCHERS, variables: { jobid } }]
});
// Toggle watcher for self
const handleToggleSelf = useCallback(() => {
(isWatching
? removeWatcher({ variables: { jobid, userEmail } })
: addWatcher({ variables: { jobid, userEmail } })
).catch((err) => console.error(`Error updating job watcher: ${err.message}`));
}, [isWatching, addWatcher, removeWatcher, jobid, userEmail]);
// Handle removing a watcher
const handleRemoveWatcher = (userEmail) => {
removeWatcher({ variables: { jobid, userEmail } }).catch((err) =>
console.error(`Error removing job watcher: ${err.message}`)
);
};
const handleWatcherSelect = (selectedUser) => {
const employee = bodyshop.employees.find((e) => e.id === selectedUser);
if (!employee) return;
const isAlreadyWatching = jobWatchers.some((w) => w.user_email === employee.user_email);
if (isAlreadyWatching) {
handleRemoveWatcher(employee.user_email);
} else {
addWatcher({ variables: { jobid, userEmail: employee.user_email } }).catch((err) =>
console.error(`Error adding job watcher: ${err.message}`)
);
}
// Clear selection
setSelectedWatcher(null);
};
const handleTeamSelect = (team) => {
const selectedTeamMembers = JSON.parse(team); // Parse the array of emails
const newWatchers = selectedTeamMembers.filter(
(email) => !jobWatchers.some((watcher) => watcher.user_email === email)
);
// Add each new watcher
newWatchers.forEach((email) => {
addWatcher({ variables: { jobid, userEmail: email } }).catch((err) =>
console.error(`Error adding job watcher: ${err.message}`)
);
});
// Clear selection
setSelectedTeam(null);
};
const handleRenderItem = (watcher) => {
const employee = bodyshop.employees.find((e) => e.user_email === watcher.user_email);
const displayName = employee ? `${employee.first_name} ${employee.last_name}` : watcher.user_email;
return (
<List.Item
actions={[
<Button type="link" danger size="small" onClick={() => handleRemoveWatcher(watcher.user_email)}>
{t("notifications.actions.remove")}
</Button>
]}
>
<List.Item.Meta
avatar={<Avatar icon={<UserOutlined />} />}
title={<Text>{displayName}</Text>}
description={watcher.user_email} // Keep the email for reference
/>
</List.Item>
);
};
// Popover content
const popoverContent = (
<div style={{ width: 600 }}>
{/* Self-toggle Button */}
<Button
block
type="text"
icon={isWatching ? <EyeOutlined /> : <EyeFilled />}
onClick={handleToggleSelf}
loading={adding || removing}
>
{isWatching ? t("notifications.tooltips.unwatch") : t("notifications.tooltips.watch")}
</Button>
{/* List of Watchers */}
<Text type="secondary" style={{ marginBottom: 8, display: "block" }}>
{t("notifications.labels.watching-issue")}
</Text>
{watcherLoading ? <LoadingSpinner /> : <List dataSource={jobWatchers} renderItem={handleRenderItem} />}
{/* Employee Search Select (for adding watchers) */}
<Divider />
<Text type="secondary">{t("notifications.labels.add-watchers")}</Text>
<EmployeeSearchSelectComponent
style={{ minWidth: "100%" }}
options={bodyshop.employees.filter((e) => jobWatchers.every((w) => w.user_email !== e.user_email))}
placeholder={t("notifications.labels.employee-search")}
value={selectedWatcher} // Controlled value
onChange={(value) => {
setSelectedWatcher(value); // Update selected state
handleWatcherSelect(value); // Add watcher logic
}}
/>
{/* Divider for UI separation */}
{/* Only show team selection if there are available teams */}
{bodyshop?.employee_teams?.length > 0 && (
<>
<Divider />
<Text type="secondary">{t("notifications.labels.add-watchers-team")}</Text>
<Select
showSearch
style={{ minWidth: "100%" }}
placeholder={t("notifications.labels.teams-search")}
value={selectedTeam} // Controlled value
onChange={handleTeamSelect}
options={bodyshop.employee_teams.map((team) => {
const teamMembers = team.employee_team_members
.map((member) => {
const employee = bodyshop.employees.find((e) => e.id === member.employeeid);
return employee ? employee.user_email : null;
})
.filter(Boolean); // Remove nulls
return {
value: JSON.stringify(teamMembers), // Store array as string
label: team.name // Use team name as label
};
})}
/>
</>
)}
</div>
);
return (
<Popover content={popoverContent} trigger="click" open={open} onOpenChange={setOpen}>
<Tooltip title={isWatching ? t("notifications.tooltips.unwatch") : t("notifications.tooltips.watch")}>
<Button
shape="circle"
type={isWatching ? "primary" : "default"}
icon={isWatching ? <EyeFilled /> : <EyeOutlined />}
loading={watcherLoading}
/>
</Tooltip>
</Popover>
);
};
export default connect(mapStateToProps)(JobWatcherToggle);

View File

@@ -56,7 +56,8 @@ import { DateTimeFormat } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import UndefinedToNull from "../../utils/undefinedtonull";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import JobWatcherToggle from "./job-watcher-toggle.component.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import JobWatcherToggleContainer from "../../components/job-watcher-toggle/job-watcher-toggle.container.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -103,6 +104,7 @@ export function JobsDetailPage({
nextFetchPolicy: "network-only"
});
const notification = useNotification();
const { scenarioNotificationsOn } = useSocket();
useEffect(() => {
//form.setFieldsValue(transormJobToForm(job));
@@ -323,7 +325,7 @@ export function JobsDetailPage({
title={
<Space>
<JobWatcherToggle job={job} />
{scenarioNotificationsOn && <JobWatcherToggleContainer job={job} />}
{job.ro_number || t("general.labels.na")}
</Space>
}

View File

@@ -20,7 +20,7 @@ import PartnerPingComponent from "../../components/partner-ping/partner-ping.com
import PrintCenterModalContainer from "../../components/print-center-modal/print-center-modal.container";
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
import { requestForToken } from "../../firebase/firebase.utils";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors";
import UpdateAlert from "../../components/update-alert/update-alert.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
@@ -143,7 +143,7 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
const fetchedAlerts = await response.json();
setAlerts(fetchedAlerts);
} catch (error) {
console.error("Error fetching alerts:", error);
console.warn("Error fetching alerts:", error.message);
}
};
@@ -167,7 +167,6 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
description: alert.description,
type: alert.type || "info",
duration: 0,
placement: "bottomRight",
closable: true,
onClose: () => {
// When the notification is closed, update displayed alerts state and localStorage

View File

@@ -351,7 +351,7 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
? window.$crisp.push(["set", "session:segments", [["allAccess"]]])
: window.$crisp.push(["set", "session:segments", [["basic"]]]);
} catch (error) {
console.error("Couldnt find $crisp.");
console.warn("Couldnt find $crisp.", error.message);
}
} catch (error) {
yield put(signInFailure(error.message));

View File

@@ -3779,19 +3779,24 @@
"teams-search": "Search for a Team",
"add-watchers-team": "Add Team Members",
"new-notification-title": "New Notification:",
"show-unread-only": "Show Unread",
"mark-all-read": "Mark Read",
"loading": "Loading Notifications..."
"show-unread-only": "Show Unread Only",
"mark-all-read": "Mark All Read",
"notification-popup-title": "Changes for Job #{{ro_number}}",
"ro-number": "RO #{{ro_number}}",
"no-watchers": "No Watchers",
"notification-settings-success": "Notification Settings saved successfully.",
"notification-settings-failure": "Error saving Notification Settings. {{error}}",
"watch": "Watch",
"unwatch": "Unwatch"
},
"actions": {
"remove": "remove"
"remove": "Remove"
},
"aria": {
"toggle": "Toggle Watching Job"
},
"tooltips": {
"watch": "Watch Job",
"unwatch": "Unwatch Job"
"job-watchers": "Job Watchers"
},
"scenarios": {
"job-assigned-to-me": "Job Assigned to Me",

View File

@@ -3769,6 +3769,7 @@
},
"notifications": {
"labels": {
"notification-center": "",
"scenario": "",
"notificationscenarios": "",
"save": "",
@@ -3776,7 +3777,17 @@
"add-watchers": "",
"employee-search": "",
"teams-search": "",
"add-watchers-team": ""
"add-watchers-team": "",
"new-notification-title": "",
"show-unread-only": "",
"mark-all-read": "",
"notification-popup-title": "",
"ro-number": "",
"no-watchers": "",
"notification-settings-success": "",
"notification-settings-failure": "",
"watch": "",
"unwatch": ""
},
"actions": {
"remove": ""
@@ -3785,8 +3796,7 @@
"toggle": ""
},
"tooltips": {
"watch": "",
"unwatch": ""
"job-watchers": ""
},
"scenarios": {
"job-assigned-to-me": "",

View File

@@ -3769,6 +3769,7 @@
},
"notifications": {
"labels": {
"notification-center": "",
"scenario": "",
"notificationscenarios": "",
"save": "",
@@ -3776,7 +3777,17 @@
"add-watchers": "",
"employee-search": "",
"teams-search": "",
"add-watchers-team": ""
"add-watchers-team": "",
"new-notification-title": "",
"show-unread-only": "",
"mark-all-read": "",
"notification-popup-title": "",
"ro-number": "",
"no-watchers": "",
"notification-settings-success": "",
"notification-settings-failure": "",
"watch": "",
"unwatch": ""
},
"actions": {
"remove": ""
@@ -3785,8 +3796,7 @@
"toggle": ""
},
"tooltips": {
"watch": "",
"unwatch": ""
"job-watchers": ""
},
"scenarios": {
"job-assigned-to-me": "",

View File

@@ -7,9 +7,9 @@ import { getMainDefinition } from "@apollo/client/utilities";
//import { split } from "apollo-link";
import apolloLogger from "apollo-link-logger";
//import axios from "axios";
import { SentryLink } from "apollo-link-sentry";
import { auth } from "../firebase/firebase.utils";
import errorLink from "../graphql/apollo-error-handling";
import { SentryLink } from "apollo-link-sentry";
//import { store } from "../redux/store";
const httpLink = new HttpLink({
@@ -143,7 +143,41 @@ middlewares.push(
new SentryLink().concat(roundTripLink.concat(retryLink.concat(errorLink.concat(authLink.concat(link)))))
);
const cache = new InMemoryCache({});
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Note: This is required because we switch from a read to an unread state with a toggle,
notifications: {
merge(existing = [], incoming = [], { readField }) {
// Create a map to deduplicate by __ref
const merged = new Map();
// Add existing items to retain cached data
existing.forEach((item) => {
const ref = readField("__ref", item);
if (ref) {
merged.set(ref, item);
}
});
// Add incoming items, overwriting duplicates
incoming.forEach((item) => {
const ref = readField("__ref", item);
if (ref) {
merged.set(ref, item);
}
});
// Return incoming to respect the current querys filter (e.g., unread-only or all)
return incoming;
}
}
}
}
}
});
const client = new ApolloClient({
link: ApolloLink.from(middlewares),
cache,
@@ -163,4 +197,5 @@ const client = new ApolloClient({
}
}
});
export default client;

View File

@@ -1,10 +1,13 @@
/** Notification Scenarios
* @description This file contains the scenarios for job notifications.
* @type {string[]}
*/
const notificationScenarios = [
"job-assigned-to-me",
"bill-posted",
"critical-parts-status-changed",
"part-marked-back-ordered",
"new-note-added",
"supplement-imported",
"schedule-dates-changed",
"tasks-updated-created",
"new-media-added-reassigned",
@@ -14,6 +17,7 @@ const notificationScenarios = [
"job-status-change",
"payment-collected-completed",
"alternate-transport-changed"
// "supplement-imported", // Disabled for now
];
export { notificationScenarios };

View File

@@ -0,0 +1,63 @@
import * as Sentry from "@sentry/react";
import { excludeGraphQLFetch } from "apollo-link-sentry";
import { useEffect } from "react";
import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from "react-router-dom";
import InstanceRenderManager from "./instanceRenderMgr";
const currentDatePST = new Date()
.toLocaleDateString("en-US", {
timeZone: "America/Los_Angeles",
year: "numeric",
month: "2-digit",
day: "2-digit"
})
.split("/")
.reverse()
.join("-");
const sentryRelease =
`${import.meta.env.VITE_APP_IS_TEST ? "test" : "production"}-${currentDatePST}-${process.env.VITE_GIT_COMMIT_HASH}`.trim();
if (!import.meta.env.DEV) {
Sentry.init({
dsn: InstanceRenderManager({
imex: "https://fd7e89369b6b4bdc9c6c4c9f22fa4ee4@o492140.ingest.sentry.io/5651027",
rome: "https://a6acc91c073e414196014b8484627a61@o492140.ingest.sentry.io/4504561071161344"
}),
release: sentryRelease,
ignoreErrors: [
"ResizeObserver loop",
"ResizeObserver loop limit exceeded",
"Module specifier, 'fs' does not start",
"Module specifier, 'zlib' does not start with",
"Messaging: This browser doesn't support the API's required to use the Firebase SDK.",
"Failed to update a ServiceWorker for scope"
],
integrations: [
// See docs for support of different versions of variation of react router
// https://docs.sentry.io/platforms/javascript/guides/react/configuration/integrations/react-router/
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes
}),
Sentry.replayIntegration(),
Sentry.browserProfilingIntegration()
],
tracePropagationTargets: [
"api.imex.online",
"api.test.imex.online",
"db.imex.online",
"api.romeonline.io",
"api.test.romeonline.io",
"db.romeonline.io"
],
tracesSampleRate: 1.0,
replaysOnErrorSampleRate: 1.0,
environment: import.meta.env.MODE,
beforeBreadcrumb: excludeGraphQLFetch
});
}

View File

@@ -1,16 +1,31 @@
import { sentryVitePlugin } from "@sentry/vite-plugin";
import react from "@vitejs/plugin-react";
import chalk from "chalk";
import * as child from "child_process";
import { promises as fsPromises } from "fs";
import { createLogger, defineConfig } from "vite";
import { ViteEjsPlugin } from "vite-plugin-ejs";
import eslint from "vite-plugin-eslint";
import { VitePWA } from "vite-plugin-pwa";
import InstanceRenderManager from "./src/utils/instanceRenderMgr";
import chalk from "chalk";
// Ensure your environment variables are set correctly for Vite 6
process.env.VITE_APP_GIT_SHA_DATE = new Date().toLocaleString("en-US", {
timeZone: "America/Los_Angeles"
});
const commitHash = child.execSync("git rev-parse HEAD").toString().trimEnd();
process.env.VITE_GIT_COMMIT_HASH = commitHash;
const currentDatePST = new Date()
.toLocaleDateString("en-US", {
timeZone: "America/Los_Angeles",
year: "numeric",
month: "2-digit",
day: "2-digit"
})
.split("/")
.reverse()
.join("-");
const getFormattedTimestamp = () =>
new Date().toLocaleTimeString("en-US", { hour12: true }).replace("AM", "a.m.").replace("PM", "p.m.");
@@ -78,10 +93,25 @@ export default defineConfig({
}
}),
react(),
eslint()
eslint(),
sentryVitePlugin({
org: "imex",
reactComponentAnnotation: {
enabled: true
},
release: {
name: `${process.env.VITE_APP_IS_TEST ? "test" : "production"}-${currentDatePST}-${commitHash}`.trim()
},
project: InstanceRenderManager({
instance: process.env.VITE_APP_INSTANCE,
imex: "imexonline",
rome: "rome-online"
})
})
],
define: {
APP_VERSION: JSON.stringify(process.env.npm_package_version)
APP_VERSION: JSON.stringify(process.env.npm_package_version),
__COMMIT_HASH__: JSON.stringify(commitHash)
},
server: {
host: true,
@@ -184,7 +214,9 @@ export default defineConfig({
"libphonenumber-js": ["libphonenumber-js"]
}
}
}
},
sourcemap: true
},
optimizeDeps: {
include: [

221
docker-compose-cluster.yml Normal file
View File

@@ -0,0 +1,221 @@
services:
# Load Balancer (NGINX) with WebSocket support and session persistence
load-balancer:
image: nginx:latest
container_name: load-balancer
ports:
- "4000:80" # External port 4000 maps to NGINX's port 80
volumes:
- ./nginx-websocket.conf:/etc/nginx/nginx.conf:ro # Mount NGINX configuration
networks:
- redis-cluster-net
depends_on:
- node-app-1
- node-app-2
- node-app-3
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost/health" ]
interval: 10s
timeout: 5s
retries: 5
# Node App Instance 1
node-app-1:
build:
context: .
container_name: node-app-1
hostname: node-app-1
networks:
- redis-cluster-net
env_file:
- .env.development
depends_on:
redis-node-1:
condition: service_healthy
redis-node-2:
condition: service_healthy
redis-node-3:
condition: service_healthy
localstack:
condition: service_healthy
aws-cli:
condition: service_completed_successfully
ports:
- "4001:4000" # Different external port for local access
volumes:
- .:/app
- node-app-npm-cache:/app/node_modules
# Node App Instance 2
node-app-2:
build:
context: .
container_name: node-app-2
hostname: node-app-2
networks:
- redis-cluster-net
env_file:
- .env.development
depends_on:
redis-node-1:
condition: service_healthy
redis-node-2:
condition: service_healthy
redis-node-3:
condition: service_healthy
localstack:
condition: service_healthy
aws-cli:
condition: service_completed_successfully
ports:
- "4002:4000" # Different external port for local access
volumes:
- .:/app
- node-app-npm-cache:/app/node_modules
# Node App Instance 3
node-app-3:
build:
context: .
container_name: node-app-3
hostname: node-app-3
networks:
- redis-cluster-net
env_file:
- .env.development
depends_on:
redis-node-1:
condition: service_healthy
redis-node-2:
condition: service_healthy
redis-node-3:
condition: service_healthy
localstack:
condition: service_healthy
aws-cli:
condition: service_completed_successfully
ports:
- "4003:4000" # Different external port for local access
volumes:
- .:/app
- node-app-npm-cache:/app/node_modules
# Redis Node 1
redis-node-1:
build:
context: ./redis
container_name: redis-node-1
hostname: redis-node-1
restart: unless-stopped
networks:
- redis-cluster-net
volumes:
- redis-node-1-data:/data
- redis-lock:/redis-lock
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 10
# Redis Node 2
redis-node-2:
build:
context: ./redis
container_name: redis-node-2
hostname: redis-node-2
restart: unless-stopped
networks:
- redis-cluster-net
volumes:
- redis-node-2-data:/data
- redis-lock:/redis-lock
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 10
# Redis Node 3
redis-node-3:
build:
context: ./redis
container_name: redis-node-3
hostname: redis-node-3
restart: unless-stopped
networks:
- redis-cluster-net
volumes:
- redis-node-3-data:/data
- redis-lock:/redis-lock
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 10
# LocalStack
localstack:
image: localstack/localstack
container_name: localstack
hostname: localstack
networks:
- redis-cluster-net
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- SERVICES=s3,ses,secretsmanager,cloudwatch,logs
- DEBUG=0
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
- AWS_DEFAULT_REGION=ca-central-1
- EXTRA_CORS_ALLOWED_HEADERS=Authorization,Content-Type
- EXTRA_CORS_ALLOWED_ORIGINS=*
- EXTRA_CORS_EXPOSE_HEADERS=Authorization,Content-Type
ports:
- "4566:4566"
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:4566/_localstack/health" ]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
# AWS-CLI
aws-cli:
image: amazon/aws-cli
container_name: aws-cli
hostname: aws-cli
networks:
- redis-cluster-net
depends_on:
localstack:
condition: service_healthy
volumes:
- './localstack:/tmp/localstack'
- './certs:/tmp/certs'
environment:
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
- AWS_DEFAULT_REGION=ca-central-1
entrypoint: /bin/sh -c
command: >
"
aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
"
networks:
redis-cluster-net:
driver: bridge
volumes:
node-app-npm-cache:
redis-node-1-data:
redis-node-2-data:
redis-node-3-data:
redis-lock:

View File

@@ -198,6 +198,14 @@
- name: user
using:
foreign_key_constraint_on: useremail
array_relationships:
- name: notifications
using:
foreign_key_constraint_on:
column: associationid
table:
name: notifications
schema: public
select_permissions:
- role: user
permission:
@@ -1127,6 +1135,46 @@
- active:
_eq: true
check: null
event_triggers:
- name: cache_bodyshop
definition:
enable_manual: false
update:
columns:
- shopname
- md_order_statuses
retry_conf:
interval_sec: 10
num_retries: 0
timeout_sec: 60
webhook_from_env: HASURA_API_URL
headers:
- name: event-secret
value_from_env: EVENT_SECRET
request_transform:
body:
action: transform
template: |-
{
"created_at": {{$body.created_at}},
"delivery_info": {{$body.delivery_info}},
"event": {
"data": {
"new": {
"id": {{$body.event.data.new.id}},
"shopname": {{$body.event.data.new.shopname}},
"md_order_statuses": {{$body.event.data.new.md_order_statuses}}
}
},
"op": {{$body.event.op}},
"session_variables": {{$body.event.session_variables}}
}
}
method: POST
query_params: {}
template_engine: Kriti
url: '{{$base_url}}/bodyshop-cache'
version: 2
- table:
name: cccontracts
schema: public
@@ -1953,9 +2001,11 @@
- active:
_eq: true
event_triggers:
- name: notifications_docuemtns
- name: notifications_documents
definition:
enable_manual: false
insert:
columns: '*'
update:
columns:
- jobid
@@ -3238,11 +3288,10 @@
- name: notifications_joblines
definition:
enable_manual: false
insert:
columns: '*'
update:
columns:
- critical
- status
retry_conf:
interval_sec: 10
num_retries: 0
@@ -3252,11 +3301,14 @@
- name: event-secret
value_from_env: EVENT_SECRET
request_transform:
body:
action: transform
template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": \"UPDATE\",\r\n \"data\": {\r\n \"old\": {\r\n \"id\": {{$body.event.data.old.id}},\r\n \"jobid\": {{$body.event.data.old.jobid}},\r\n \"critical\": {{$body.event.data.old.critical}},\r\n \"status\": {{$body.event.data.old.status}},\r\n \"line_desc\": {{$body.event.data.old.line_desc}}\r\n },\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"jobid\": {{$body.event.data.new.jobid}},\r\n \"critical\": {{$body.event.data.new.critical}},\r\n \"status\": {{$body.event.data.new.status}},\r\n \"line_desc\": {{$body.event.data.new.line_desc}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_joblines\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"joblines\"\r\n }\r\n}\r\n"
method: POST
query_params: {}
template_engine: Kriti
url: '{{$base_url}}/notifications/events/handleJobLinesChange'
version: 1
version: 2
- table:
name: joblines_status
schema: public
@@ -3440,6 +3492,13 @@
table:
name: notes
schema: public
- name: notifications
using:
foreign_key_constraint_on:
column: jobid
table:
name: notifications
schema: public
- name: parts_dispatches
using:
foreign_key_constraint_on:
@@ -4514,7 +4573,7 @@
request_transform:
body:
action: transform
template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body.event.session_variables.x-hasura-user-id}}\r\n }, \r\n \"op\": \"UPDATE\",\r\n \"data\": {\r\n \"old\": {\r\n \"id\": {{$body.event.data.old.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.old.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.old.employee_prep}},\r\n \"clm_total\": {{$body.event.data.old.clm_total}},\r\n \"towin\": {{$body.event.data.old.towin}},\r\n \"employee_body\": {{$body.event.data.old.employee_body}},\r\n \"converted\": {{$body.event.data.old.converted}},\r\n \"scheduled_in\": {{$body.event.data.old.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.old.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.old.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.old.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.old.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.old.alt_transport}},\r\n \"date_exported\": {{$body.event.data.old.date_exported}},\r\n \"status\": {{$body.event.data.old.status}},\r\n \"employee_csr\": {{$body.event.data.old.employee_csr}},\r\n \"actual_in\": {{$body.event.data.old.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.old.deliverchecklist}},\r\n \"comment\": {{$body.event.data.old.comment}},\r\n \"employee_refinish\": {{$body.event.data.old.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.old.inproduction}},\r\n \"production_vars\": {{$body.event.data.old.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.old.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.old.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.old.date_invoiced}}\r\n },\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.new.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.new.employee_prep}},\r\n \"clm_total\": {{$body.event.data.new.clm_total}},\r\n \"towin\": {{$body.event.data.new.towin}},\r\n \"employee_body\": {{$body.event.data.new.employee_body}},\r\n \"converted\": {{$body.event.data.new.converted}},\r\n \"scheduled_in\": {{$body.event.data.new.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.new.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.new.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.new.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.new.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.new.alt_transport}},\r\n \"date_exported\": {{$body.event.data.new.date_exported}},\r\n \"status\": {{$body.event.data.new.status}},\r\n \"employee_csr\": {{$body.event.data.new.employee_csr}},\r\n \"actual_in\": {{$body.event.data.new.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.new.deliverchecklist}},\r\n \"comment\": {{$body.event.data.new.comment}},\r\n \"employee_refinish\": {{$body.event.data.new.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.new.inproduction}},\r\n \"production_vars\": {{$body.event.data.new.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.new.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.new.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.new.date_invoiced}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n"
template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": \"UPDATE\",\r\n \"data\": {\r\n \"old\": {\r\n \"id\": {{$body.event.data.old.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.old.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.old.employee_prep}},\r\n \"clm_total\": {{$body.event.data.old.clm_total}},\r\n \"towin\": {{$body.event.data.old.towin}},\r\n \"employee_body\": {{$body.event.data.old.employee_body}},\r\n \"converted\": {{$body.event.data.old.converted}},\r\n \"scheduled_in\": {{$body.event.data.old.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.old.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.old.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.old.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.old.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.old.alt_transport}},\r\n \"date_exported\": {{$body.event.data.old.date_exported}},\r\n \"status\": {{$body.event.data.old.status}},\r\n \"employee_csr\": {{$body.event.data.old.employee_csr}},\r\n \"actual_in\": {{$body.event.data.old.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.old.deliverchecklist}},\r\n \"comment\": {{$body.event.data.old.comment}},\r\n \"employee_refinish\": {{$body.event.data.old.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.old.inproduction}},\r\n \"production_vars\": {{$body.event.data.old.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.old.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.old.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.old.date_invoiced}}\r\n },\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.new.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.new.employee_prep}},\r\n \"clm_total\": {{$body.event.data.new.clm_total}},\r\n \"towin\": {{$body.event.data.new.towin}},\r\n \"employee_body\": {{$body.event.data.new.employee_body}},\r\n \"converted\": {{$body.event.data.new.converted}},\r\n \"scheduled_in\": {{$body.event.data.new.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.new.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.new.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.new.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.new.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.new.alt_transport}},\r\n \"date_exported\": {{$body.event.data.new.date_exported}},\r\n \"status\": {{$body.event.data.new.status}},\r\n \"employee_csr\": {{$body.event.data.new.employee_csr}},\r\n \"actual_in\": {{$body.event.data.new.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.new.deliverchecklist}},\r\n \"comment\": {{$body.event.data.new.comment}},\r\n \"employee_refinish\": {{$body.event.data.new.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.new.inproduction}},\r\n \"production_vars\": {{$body.event.data.new.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.new.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.new.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.new.date_invoiced}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n"
method: POST
query_params: {}
template_engine: Kriti
@@ -5207,32 +5266,6 @@
- active:
_eq: true
check: null
event_triggers:
- name: notifications_parts_dispatch
definition:
enable_manual: false
insert:
columns: '*'
retry_conf:
interval_sec: 10
num_retries: 0
timeout_sec: 60
webhook_from_env: HASURA_API_URL
headers:
- name: event-secret
value_from_env: EVENT_SECRET
request_transform:
body:
action: transform
template: |-
{
"success": true
}
method: POST
query_params: {}
template_engine: Kriti
url: '{{$base_url}}/notifications/events/handlePartsDispatchChange'
version: 2
- table:
name: parts_dispatch_lines
schema: public
@@ -6231,10 +6264,12 @@
columns:
- joblineid
- assigned_to
- due_date
- partsorderid
- completed
- description
- billid
- title
- priority
retry_conf:
interval_sec: 10
@@ -6688,6 +6723,13 @@
table:
name: ioevents
schema: public
- name: job_watchers
using:
foreign_key_constraint_on:
column: user_email
table:
name: job_watchers
schema: public
- name: messages
using:
foreign_key_constraint_on:

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "public"."idx_job_watchers_jobid_user_email_unique";

View File

@@ -0,0 +1,2 @@
CREATE UNIQUE INDEX "idx_job_watchers_jobid_user_email_unique" on
"public"."job_watchers" using btree ("jobid", "user_email");

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "public"."notificiations_idx_jobs";

View File

@@ -0,0 +1,2 @@
CREATE INDEX "notificiations_idx_jobs" on
"public"."notifications" using btree ("jobid");

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "public"."notifications_idx_associations";

View File

@@ -0,0 +1,2 @@
CREATE INDEX "notifications_idx_associations" on
"public"."notifications" using btree ("associationid");

View File

@@ -0,0 +1,3 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- CREATE INDEX idx_notifications_created_at_not_read ON notifications(created_at desc, read) where read is null;

View File

@@ -0,0 +1 @@
CREATE INDEX idx_notifications_created_at_not_read ON notifications(created_at desc, read) where read is null;

View File

@@ -0,0 +1,3 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- CREATE INDEX idx_notifications_associations_not_read ON notifications(associationid, read) where read is null;

View File

@@ -0,0 +1 @@
CREATE INDEX idx_notifications_associations_not_read ON notifications(associationid, read) where read is null;

45
nginx-websocket.conf Normal file
View File

@@ -0,0 +1,45 @@
events {
worker_connections 1024;
}
http {
upstream node_app {
ip_hash; # Enables session persistence based on client IP
server node-app-1:4000;
server node-app-2:4000;
server node-app-3:4000;
}
# WebSocket upgrade configuration
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
location / {
proxy_pass http://node_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket headers
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400; # Keep WebSocket connections alive (24 hours)
}
# Health check endpoint
location /health {
proxy_pass http://node_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

469
package-lock.json generated
View File

@@ -9,12 +9,12 @@
"version": "0.2.0",
"license": "UNLICENSED",
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.750.0",
"@aws-sdk/client-elasticache": "^3.755.0",
"@aws-sdk/client-s3": "^3.750.0",
"@aws-sdk/client-secrets-manager": "^3.750.0",
"@aws-sdk/client-ses": "^3.750.0",
"@aws-sdk/credential-provider-node": "^3.750.0",
"@aws-sdk/client-cloudwatch-logs": "^3.758.0",
"@aws-sdk/client-elasticache": "^3.758.0",
"@aws-sdk/client-s3": "^3.758.0",
"@aws-sdk/client-secrets-manager": "^3.758.0",
"@aws-sdk/client-ses": "^3.758.0",
"@aws-sdk/credential-provider-node": "^3.758.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
@@ -32,7 +32,7 @@
"cors": "2.8.5",
"crisp-status-reporter": "^1.2.2",
"csrf": "^3.1.0",
"dd-trace": "^5.39.0",
"dd-trace": "^5.40.0",
"dinero.js": "^1.9.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
@@ -43,7 +43,7 @@
"intuit-oauth": "^4.2.0",
"ioredis": "^5.5.0",
"json-2-csv": "^5.5.8",
"juice": "^11.0.0",
"juice": "^11.0.1",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"moment-timezone": "^0.5.47",
@@ -56,7 +56,7 @@
"redis": "^4.7.0",
"rimraf": "^6.0.1",
"skia-canvas": "^2.0.2",
"soap": "^1.1.8",
"soap": "^1.1.9",
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5",
"ssh2-sftp-client": "^11.0.0",
@@ -75,7 +75,7 @@
"eslint-plugin-react": "^7.37.4",
"globals": "^15.15.0",
"p-limit": "^3.1.0",
"prettier": "^3.5.2",
"prettier": "^3.5.3",
"source-map-explorer": "^2.5.2"
},
"engines": {
@@ -286,26 +286,26 @@
}
},
"node_modules/@aws-sdk/client-cloudwatch-logs": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.750.0.tgz",
"integrity": "sha512-nXzQ+x6tPKSzXE9eo4IMuxQr/Cc+R53CFBqnmq3WKJEUao7cXxLKmxC/6NiJg89Vif9QEeuP4T0hTjyIHsYezg==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.758.0.tgz",
"integrity": "sha512-IlEIm5h4vfeoZyY8Op4W6lX1lqcEYE3DRKl+fMKRTFttvJ+AJfuZlAgFlMh9OPFQ0ZMLe8etoxHwKN50YCLivw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.750.0",
"@aws-sdk/credential-provider-node": "3.750.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/credential-provider-node": "3.758.0",
"@aws-sdk/middleware-host-header": "3.734.0",
"@aws-sdk/middleware-logger": "3.734.0",
"@aws-sdk/middleware-recursion-detection": "3.734.0",
"@aws-sdk/middleware-user-agent": "3.750.0",
"@aws-sdk/middleware-user-agent": "3.758.0",
"@aws-sdk/region-config-resolver": "3.734.0",
"@aws-sdk/types": "3.734.0",
"@aws-sdk/util-endpoints": "3.743.0",
"@aws-sdk/util-user-agent-browser": "3.734.0",
"@aws-sdk/util-user-agent-node": "3.750.0",
"@aws-sdk/util-user-agent-node": "3.758.0",
"@smithy/config-resolver": "^4.0.1",
"@smithy/core": "^3.1.4",
"@smithy/core": "^3.1.5",
"@smithy/eventstream-serde-browser": "^4.0.1",
"@smithy/eventstream-serde-config-resolver": "^4.0.1",
"@smithy/eventstream-serde-node": "^4.0.1",
@@ -313,21 +313,21 @@
"@smithy/hash-node": "^4.0.1",
"@smithy/invalid-dependency": "^4.0.1",
"@smithy/middleware-content-length": "^4.0.1",
"@smithy/middleware-endpoint": "^4.0.5",
"@smithy/middleware-retry": "^4.0.6",
"@smithy/middleware-endpoint": "^4.0.6",
"@smithy/middleware-retry": "^4.0.7",
"@smithy/middleware-serde": "^4.0.2",
"@smithy/middleware-stack": "^4.0.1",
"@smithy/node-config-provider": "^4.0.1",
"@smithy/node-http-handler": "^4.0.2",
"@smithy/node-http-handler": "^4.0.3",
"@smithy/protocol-http": "^5.0.1",
"@smithy/smithy-client": "^4.1.5",
"@smithy/smithy-client": "^4.1.6",
"@smithy/types": "^4.1.0",
"@smithy/url-parser": "^4.0.1",
"@smithy/util-base64": "^4.0.0",
"@smithy/util-body-length-browser": "^4.0.0",
"@smithy/util-body-length-node": "^4.0.0",
"@smithy/util-defaults-mode-browser": "^4.0.6",
"@smithy/util-defaults-mode-node": "^4.0.6",
"@smithy/util-defaults-mode-browser": "^4.0.7",
"@smithy/util-defaults-mode-node": "^4.0.7",
"@smithy/util-endpoints": "^3.0.1",
"@smithy/util-middleware": "^4.0.1",
"@smithy/util-retry": "^4.0.1",
@@ -354,45 +354,45 @@
}
},
"node_modules/@aws-sdk/client-elasticache": {
"version": "3.755.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-elasticache/-/client-elasticache-3.755.0.tgz",
"integrity": "sha512-8BQb92HtloPR8b0EilHexTCXL5ASk+E4fZn2RLsEtjY/JjSAezvv2RT0QJ7j/h/fQ6CBzq/oi87IPqMBZ0qfYw==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-elasticache/-/client-elasticache-3.758.0.tgz",
"integrity": "sha512-qmDOTHhB0hUm/Ifypi6+zjUR4dl7H576oM4/p2RUgkjyz2RgJaLJhyX32TDDzcX2maevNHJ3TijXOkGxoGDeog==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.750.0",
"@aws-sdk/credential-provider-node": "3.750.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/credential-provider-node": "3.758.0",
"@aws-sdk/middleware-host-header": "3.734.0",
"@aws-sdk/middleware-logger": "3.734.0",
"@aws-sdk/middleware-recursion-detection": "3.734.0",
"@aws-sdk/middleware-user-agent": "3.750.0",
"@aws-sdk/middleware-user-agent": "3.758.0",
"@aws-sdk/region-config-resolver": "3.734.0",
"@aws-sdk/types": "3.734.0",
"@aws-sdk/util-endpoints": "3.743.0",
"@aws-sdk/util-user-agent-browser": "3.734.0",
"@aws-sdk/util-user-agent-node": "3.750.0",
"@aws-sdk/util-user-agent-node": "3.758.0",
"@smithy/config-resolver": "^4.0.1",
"@smithy/core": "^3.1.4",
"@smithy/core": "^3.1.5",
"@smithy/fetch-http-handler": "^5.0.1",
"@smithy/hash-node": "^4.0.1",
"@smithy/invalid-dependency": "^4.0.1",
"@smithy/middleware-content-length": "^4.0.1",
"@smithy/middleware-endpoint": "^4.0.5",
"@smithy/middleware-retry": "^4.0.6",
"@smithy/middleware-endpoint": "^4.0.6",
"@smithy/middleware-retry": "^4.0.7",
"@smithy/middleware-serde": "^4.0.2",
"@smithy/middleware-stack": "^4.0.1",
"@smithy/node-config-provider": "^4.0.1",
"@smithy/node-http-handler": "^4.0.2",
"@smithy/node-http-handler": "^4.0.3",
"@smithy/protocol-http": "^5.0.1",
"@smithy/smithy-client": "^4.1.5",
"@smithy/smithy-client": "^4.1.6",
"@smithy/types": "^4.1.0",
"@smithy/url-parser": "^4.0.1",
"@smithy/util-base64": "^4.0.0",
"@smithy/util-body-length-browser": "^4.0.0",
"@smithy/util-body-length-node": "^4.0.0",
"@smithy/util-defaults-mode-browser": "^4.0.6",
"@smithy/util-defaults-mode-node": "^4.0.6",
"@smithy/util-defaults-mode-browser": "^4.0.7",
"@smithy/util-defaults-mode-node": "^4.0.7",
"@smithy/util-endpoints": "^3.0.1",
"@smithy/util-middleware": "^4.0.1",
"@smithy/util-retry": "^4.0.1",
@@ -405,35 +405,35 @@
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.750.0.tgz",
"integrity": "sha512-S9G9noCeBxchoMVkHYrRi1A1xW/VOTP2W7X34lP+Y7Wpl32yMA7IJo0fAGAuTc0q1Nu6/pXDm+oDG7rhTCA1tg==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.758.0.tgz",
"integrity": "sha512-f8SlhU9/93OC/WEI6xVJf/x/GoQFj9a/xXK6QCtr5fvCjfSLgMVFmKTiIl/tgtDRzxUDc8YS6EGtbHjJ3Y/atg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.750.0",
"@aws-sdk/credential-provider-node": "3.750.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/credential-provider-node": "3.758.0",
"@aws-sdk/middleware-bucket-endpoint": "3.734.0",
"@aws-sdk/middleware-expect-continue": "3.734.0",
"@aws-sdk/middleware-flexible-checksums": "3.750.0",
"@aws-sdk/middleware-flexible-checksums": "3.758.0",
"@aws-sdk/middleware-host-header": "3.734.0",
"@aws-sdk/middleware-location-constraint": "3.734.0",
"@aws-sdk/middleware-logger": "3.734.0",
"@aws-sdk/middleware-recursion-detection": "3.734.0",
"@aws-sdk/middleware-sdk-s3": "3.750.0",
"@aws-sdk/middleware-sdk-s3": "3.758.0",
"@aws-sdk/middleware-ssec": "3.734.0",
"@aws-sdk/middleware-user-agent": "3.750.0",
"@aws-sdk/middleware-user-agent": "3.758.0",
"@aws-sdk/region-config-resolver": "3.734.0",
"@aws-sdk/signature-v4-multi-region": "3.750.0",
"@aws-sdk/signature-v4-multi-region": "3.758.0",
"@aws-sdk/types": "3.734.0",
"@aws-sdk/util-endpoints": "3.743.0",
"@aws-sdk/util-user-agent-browser": "3.734.0",
"@aws-sdk/util-user-agent-node": "3.750.0",
"@aws-sdk/util-user-agent-node": "3.758.0",
"@aws-sdk/xml-builder": "3.734.0",
"@smithy/config-resolver": "^4.0.1",
"@smithy/core": "^3.1.4",
"@smithy/core": "^3.1.5",
"@smithy/eventstream-serde-browser": "^4.0.1",
"@smithy/eventstream-serde-config-resolver": "^4.0.1",
"@smithy/eventstream-serde-node": "^4.0.1",
@@ -444,25 +444,25 @@
"@smithy/invalid-dependency": "^4.0.1",
"@smithy/md5-js": "^4.0.1",
"@smithy/middleware-content-length": "^4.0.1",
"@smithy/middleware-endpoint": "^4.0.5",
"@smithy/middleware-retry": "^4.0.6",
"@smithy/middleware-endpoint": "^4.0.6",
"@smithy/middleware-retry": "^4.0.7",
"@smithy/middleware-serde": "^4.0.2",
"@smithy/middleware-stack": "^4.0.1",
"@smithy/node-config-provider": "^4.0.1",
"@smithy/node-http-handler": "^4.0.2",
"@smithy/node-http-handler": "^4.0.3",
"@smithy/protocol-http": "^5.0.1",
"@smithy/smithy-client": "^4.1.5",
"@smithy/smithy-client": "^4.1.6",
"@smithy/types": "^4.1.0",
"@smithy/url-parser": "^4.0.1",
"@smithy/util-base64": "^4.0.0",
"@smithy/util-body-length-browser": "^4.0.0",
"@smithy/util-body-length-node": "^4.0.0",
"@smithy/util-defaults-mode-browser": "^4.0.6",
"@smithy/util-defaults-mode-node": "^4.0.6",
"@smithy/util-defaults-mode-browser": "^4.0.7",
"@smithy/util-defaults-mode-node": "^4.0.7",
"@smithy/util-endpoints": "^3.0.1",
"@smithy/util-middleware": "^4.0.1",
"@smithy/util-retry": "^4.0.1",
"@smithy/util-stream": "^4.1.1",
"@smithy/util-stream": "^4.1.2",
"@smithy/util-utf8": "^4.0.0",
"@smithy/util-waiter": "^4.0.2",
"tslib": "^2.6.2"
@@ -472,45 +472,45 @@
}
},
"node_modules/@aws-sdk/client-secrets-manager": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.750.0.tgz",
"integrity": "sha512-5JrrOQECJtcUFodKqBNKTk82WycIu/4cVFYf6QXsZQ/0bJ8zlp3vDyTeAjLriZXRXrb8HZlWqOsPCPT3wEBYdg==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.758.0.tgz",
"integrity": "sha512-Vi4cdCim0jQx3rrU5R1W4v3czoWL0ajBtoI15oSSt7cwLjzNA0xq4nXSa6rahjTgtZWlLeBprbquvxNzY3qg5Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.750.0",
"@aws-sdk/credential-provider-node": "3.750.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/credential-provider-node": "3.758.0",
"@aws-sdk/middleware-host-header": "3.734.0",
"@aws-sdk/middleware-logger": "3.734.0",
"@aws-sdk/middleware-recursion-detection": "3.734.0",
"@aws-sdk/middleware-user-agent": "3.750.0",
"@aws-sdk/middleware-user-agent": "3.758.0",
"@aws-sdk/region-config-resolver": "3.734.0",
"@aws-sdk/types": "3.734.0",
"@aws-sdk/util-endpoints": "3.743.0",
"@aws-sdk/util-user-agent-browser": "3.734.0",
"@aws-sdk/util-user-agent-node": "3.750.0",
"@aws-sdk/util-user-agent-node": "3.758.0",
"@smithy/config-resolver": "^4.0.1",
"@smithy/core": "^3.1.4",
"@smithy/core": "^3.1.5",
"@smithy/fetch-http-handler": "^5.0.1",
"@smithy/hash-node": "^4.0.1",
"@smithy/invalid-dependency": "^4.0.1",
"@smithy/middleware-content-length": "^4.0.1",
"@smithy/middleware-endpoint": "^4.0.5",
"@smithy/middleware-retry": "^4.0.6",
"@smithy/middleware-endpoint": "^4.0.6",
"@smithy/middleware-retry": "^4.0.7",
"@smithy/middleware-serde": "^4.0.2",
"@smithy/middleware-stack": "^4.0.1",
"@smithy/node-config-provider": "^4.0.1",
"@smithy/node-http-handler": "^4.0.2",
"@smithy/node-http-handler": "^4.0.3",
"@smithy/protocol-http": "^5.0.1",
"@smithy/smithy-client": "^4.1.5",
"@smithy/smithy-client": "^4.1.6",
"@smithy/types": "^4.1.0",
"@smithy/url-parser": "^4.0.1",
"@smithy/util-base64": "^4.0.0",
"@smithy/util-body-length-browser": "^4.0.0",
"@smithy/util-body-length-node": "^4.0.0",
"@smithy/util-defaults-mode-browser": "^4.0.6",
"@smithy/util-defaults-mode-node": "^4.0.6",
"@smithy/util-defaults-mode-browser": "^4.0.7",
"@smithy/util-defaults-mode-node": "^4.0.7",
"@smithy/util-endpoints": "^3.0.1",
"@smithy/util-middleware": "^4.0.1",
"@smithy/util-retry": "^4.0.1",
@@ -537,45 +537,45 @@
}
},
"node_modules/@aws-sdk/client-ses": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.750.0.tgz",
"integrity": "sha512-0apX2PEzT/09XiO42jNHjkszz/k2RLcIiaLbl1ngcKY1lWzMzIiGIqXw7Emei8iye2o6EsWuBG1p3k30iSyjhg==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.758.0.tgz",
"integrity": "sha512-cWBjZqY7SsFdTTSw3726DEPy3d7FfQ8qrw21RCukM/p3Ty42NWauHkqgxOmRygeiSY3ygHmWexc32B+4RXXqTw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.750.0",
"@aws-sdk/credential-provider-node": "3.750.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/credential-provider-node": "3.758.0",
"@aws-sdk/middleware-host-header": "3.734.0",
"@aws-sdk/middleware-logger": "3.734.0",
"@aws-sdk/middleware-recursion-detection": "3.734.0",
"@aws-sdk/middleware-user-agent": "3.750.0",
"@aws-sdk/middleware-user-agent": "3.758.0",
"@aws-sdk/region-config-resolver": "3.734.0",
"@aws-sdk/types": "3.734.0",
"@aws-sdk/util-endpoints": "3.743.0",
"@aws-sdk/util-user-agent-browser": "3.734.0",
"@aws-sdk/util-user-agent-node": "3.750.0",
"@aws-sdk/util-user-agent-node": "3.758.0",
"@smithy/config-resolver": "^4.0.1",
"@smithy/core": "^3.1.4",
"@smithy/core": "^3.1.5",
"@smithy/fetch-http-handler": "^5.0.1",
"@smithy/hash-node": "^4.0.1",
"@smithy/invalid-dependency": "^4.0.1",
"@smithy/middleware-content-length": "^4.0.1",
"@smithy/middleware-endpoint": "^4.0.5",
"@smithy/middleware-retry": "^4.0.6",
"@smithy/middleware-endpoint": "^4.0.6",
"@smithy/middleware-retry": "^4.0.7",
"@smithy/middleware-serde": "^4.0.2",
"@smithy/middleware-stack": "^4.0.1",
"@smithy/node-config-provider": "^4.0.1",
"@smithy/node-http-handler": "^4.0.2",
"@smithy/node-http-handler": "^4.0.3",
"@smithy/protocol-http": "^5.0.1",
"@smithy/smithy-client": "^4.1.5",
"@smithy/smithy-client": "^4.1.6",
"@smithy/types": "^4.1.0",
"@smithy/url-parser": "^4.0.1",
"@smithy/util-base64": "^4.0.0",
"@smithy/util-body-length-browser": "^4.0.0",
"@smithy/util-body-length-node": "^4.0.0",
"@smithy/util-defaults-mode-browser": "^4.0.6",
"@smithy/util-defaults-mode-node": "^4.0.6",
"@smithy/util-defaults-mode-browser": "^4.0.7",
"@smithy/util-defaults-mode-node": "^4.0.7",
"@smithy/util-endpoints": "^3.0.1",
"@smithy/util-middleware": "^4.0.1",
"@smithy/util-retry": "^4.0.1",
@@ -588,44 +588,44 @@
}
},
"node_modules/@aws-sdk/client-sso": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.750.0.tgz",
"integrity": "sha512-y0Rx6pTQXw0E61CaptpZF65qNggjqOgymq/RYZU5vWba5DGQ+iqGt8Yq8s+jfBoBBNXshxq8l8Dl5Uq/JTY1wg==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.758.0.tgz",
"integrity": "sha512-BoGO6IIWrLyLxQG6txJw6RT2urmbtlwfggapNCrNPyYjlXpzTSJhBYjndg7TpDATFd0SXL0zm8y/tXsUXNkdYQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.750.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/middleware-host-header": "3.734.0",
"@aws-sdk/middleware-logger": "3.734.0",
"@aws-sdk/middleware-recursion-detection": "3.734.0",
"@aws-sdk/middleware-user-agent": "3.750.0",
"@aws-sdk/middleware-user-agent": "3.758.0",
"@aws-sdk/region-config-resolver": "3.734.0",
"@aws-sdk/types": "3.734.0",
"@aws-sdk/util-endpoints": "3.743.0",
"@aws-sdk/util-user-agent-browser": "3.734.0",
"@aws-sdk/util-user-agent-node": "3.750.0",
"@aws-sdk/util-user-agent-node": "3.758.0",
"@smithy/config-resolver": "^4.0.1",
"@smithy/core": "^3.1.4",
"@smithy/core": "^3.1.5",
"@smithy/fetch-http-handler": "^5.0.1",
"@smithy/hash-node": "^4.0.1",
"@smithy/invalid-dependency": "^4.0.1",
"@smithy/middleware-content-length": "^4.0.1",
"@smithy/middleware-endpoint": "^4.0.5",
"@smithy/middleware-retry": "^4.0.6",
"@smithy/middleware-endpoint": "^4.0.6",
"@smithy/middleware-retry": "^4.0.7",
"@smithy/middleware-serde": "^4.0.2",
"@smithy/middleware-stack": "^4.0.1",
"@smithy/node-config-provider": "^4.0.1",
"@smithy/node-http-handler": "^4.0.2",
"@smithy/node-http-handler": "^4.0.3",
"@smithy/protocol-http": "^5.0.1",
"@smithy/smithy-client": "^4.1.5",
"@smithy/smithy-client": "^4.1.6",
"@smithy/types": "^4.1.0",
"@smithy/url-parser": "^4.0.1",
"@smithy/util-base64": "^4.0.0",
"@smithy/util-body-length-browser": "^4.0.0",
"@smithy/util-body-length-node": "^4.0.0",
"@smithy/util-defaults-mode-browser": "^4.0.6",
"@smithy/util-defaults-mode-node": "^4.0.6",
"@smithy/util-defaults-mode-browser": "^4.0.7",
"@smithy/util-defaults-mode-node": "^4.0.7",
"@smithy/util-endpoints": "^3.0.1",
"@smithy/util-middleware": "^4.0.1",
"@smithy/util-retry": "^4.0.1",
@@ -637,18 +637,18 @@
}
},
"node_modules/@aws-sdk/core": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.750.0.tgz",
"integrity": "sha512-bZ5K7N5L4+Pa2epbVpUQqd1XLG2uU8BGs/Sd+2nbgTf+lNQJyIxAg/Qsrjz9MzmY8zzQIeRQEkNmR6yVAfCmmQ==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.758.0.tgz",
"integrity": "sha512-0RswbdR9jt/XKemaLNuxi2gGr4xGlHyGxkTdhSQzCyUe9A9OPCoLl3rIESRguQEech+oJnbHk/wuiwHqTuP9sg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "3.734.0",
"@smithy/core": "^3.1.4",
"@smithy/core": "^3.1.5",
"@smithy/node-config-provider": "^4.0.1",
"@smithy/property-provider": "^4.0.1",
"@smithy/protocol-http": "^5.0.1",
"@smithy/signature-v4": "^5.0.1",
"@smithy/smithy-client": "^4.1.5",
"@smithy/smithy-client": "^4.1.6",
"@smithy/types": "^4.1.0",
"@smithy/util-middleware": "^4.0.1",
"fast-xml-parser": "4.4.1",
@@ -659,12 +659,12 @@
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.750.0.tgz",
"integrity": "sha512-In6bsG0p/P31HcH4DBRKBbcDS/3SHvEPjfXV8ODPWZO/l3/p7IRoYBdQ07C9R+VMZU2D0+/Sc/DWK/TUNDk1+Q==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.758.0.tgz",
"integrity": "sha512-N27eFoRrO6MeUNumtNHDW9WOiwfd59LPXPqDrIa3kWL/s+fOKFHb9xIcF++bAwtcZnAxKkgpDCUP+INNZskE+w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.750.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/types": "3.734.0",
"@smithy/property-provider": "^4.0.1",
"@smithy/types": "^4.1.0",
@@ -675,20 +675,20 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.750.0.tgz",
"integrity": "sha512-wFB9qqfa20AB0dElsQz5ZlZT5o+a+XzpEpmg0erylmGYqEOvh8NQWfDUVpRmQuGq9VbvW/8cIbxPoNqEbPtuWQ==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.758.0.tgz",
"integrity": "sha512-Xt9/U8qUCiw1hihztWkNeIR+arg6P+yda10OuCHX6kFVx3auTlU7+hCqs3UxqniGU4dguHuftf3mRpi5/GJ33Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.750.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/types": "3.734.0",
"@smithy/fetch-http-handler": "^5.0.1",
"@smithy/node-http-handler": "^4.0.2",
"@smithy/node-http-handler": "^4.0.3",
"@smithy/property-provider": "^4.0.1",
"@smithy/protocol-http": "^5.0.1",
"@smithy/smithy-client": "^4.1.5",
"@smithy/smithy-client": "^4.1.6",
"@smithy/types": "^4.1.0",
"@smithy/util-stream": "^4.1.1",
"@smithy/util-stream": "^4.1.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -696,18 +696,18 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.750.0.tgz",
"integrity": "sha512-2YIZmyEr5RUd3uxXpxOLD9G67Bibm4I/65M6vKFP17jVMUT+R1nL7mKqmhEVO2p+BoeV+bwMyJ/jpTYG368PCg==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.758.0.tgz",
"integrity": "sha512-cymSKMcP5d+OsgetoIZ5QCe1wnp2Q/tq+uIxVdh9MbfdBBEnl9Ecq6dH6VlYS89sp4QKuxHxkWXVnbXU3Q19Aw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.750.0",
"@aws-sdk/credential-provider-env": "3.750.0",
"@aws-sdk/credential-provider-http": "3.750.0",
"@aws-sdk/credential-provider-process": "3.750.0",
"@aws-sdk/credential-provider-sso": "3.750.0",
"@aws-sdk/credential-provider-web-identity": "3.750.0",
"@aws-sdk/nested-clients": "3.750.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/credential-provider-env": "3.758.0",
"@aws-sdk/credential-provider-http": "3.758.0",
"@aws-sdk/credential-provider-process": "3.758.0",
"@aws-sdk/credential-provider-sso": "3.758.0",
"@aws-sdk/credential-provider-web-identity": "3.758.0",
"@aws-sdk/nested-clients": "3.758.0",
"@aws-sdk/types": "3.734.0",
"@smithy/credential-provider-imds": "^4.0.1",
"@smithy/property-provider": "^4.0.1",
@@ -720,17 +720,17 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.750.0.tgz",
"integrity": "sha512-THWHHAceLwsOiowPEmKyhWVDlEUxH07GHSw5AQFDvNQtGKOQl0HSIFO1mKObT2Q2Vqzji9Bq8H58SO5BFtNPRw==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.758.0.tgz",
"integrity": "sha512-+DaMv63wiq7pJrhIQzZYMn4hSarKiizDoJRvyR7WGhnn0oQ/getX9Z0VNCV3i7lIFoLNTb7WMmQ9k7+z/uD5EQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "3.750.0",
"@aws-sdk/credential-provider-http": "3.750.0",
"@aws-sdk/credential-provider-ini": "3.750.0",
"@aws-sdk/credential-provider-process": "3.750.0",
"@aws-sdk/credential-provider-sso": "3.750.0",
"@aws-sdk/credential-provider-web-identity": "3.750.0",
"@aws-sdk/credential-provider-env": "3.758.0",
"@aws-sdk/credential-provider-http": "3.758.0",
"@aws-sdk/credential-provider-ini": "3.758.0",
"@aws-sdk/credential-provider-process": "3.758.0",
"@aws-sdk/credential-provider-sso": "3.758.0",
"@aws-sdk/credential-provider-web-identity": "3.758.0",
"@aws-sdk/types": "3.734.0",
"@smithy/credential-provider-imds": "^4.0.1",
"@smithy/property-provider": "^4.0.1",
@@ -743,12 +743,12 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.750.0.tgz",
"integrity": "sha512-Q78SCH1n0m7tpu36sJwfrUSxI8l611OyysjQeMiIOliVfZICEoHcLHLcLkiR+tnIpZ3rk7d2EQ6R1jwlXnalMQ==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.758.0.tgz",
"integrity": "sha512-AzcY74QTPqcbXWVgjpPZ3HOmxQZYPROIBz2YINF0OQk0MhezDWV/O7Xec+K1+MPGQO3qS6EDrUUlnPLjsqieHA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.750.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/types": "3.734.0",
"@smithy/property-provider": "^4.0.1",
"@smithy/shared-ini-file-loader": "^4.0.1",
@@ -760,14 +760,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.750.0.tgz",
"integrity": "sha512-FGYrDjXN/FOQVi/t8fHSv8zCk+NEvtFnuc4cZUj5OIbM4vrfFc5VaPyn41Uza3iv6Qq9rZg0QOwWnqK8lNrqUw==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.758.0.tgz",
"integrity": "sha512-x0FYJqcOLUCv8GLLFDYMXRAQKGjoM+L0BG4BiHYZRDf24yQWFCAZsCQAYKo6XZYh2qznbsW6f//qpyJ5b0QVKQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/client-sso": "3.750.0",
"@aws-sdk/core": "3.750.0",
"@aws-sdk/token-providers": "3.750.0",
"@aws-sdk/client-sso": "3.758.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/token-providers": "3.758.0",
"@aws-sdk/types": "3.734.0",
"@smithy/property-provider": "^4.0.1",
"@smithy/shared-ini-file-loader": "^4.0.1",
@@ -779,13 +779,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.750.0.tgz",
"integrity": "sha512-Nz8zs3YJ+GOTSrq+LyzbbC1Ffpt7pK38gcOyNZv76pP5MswKTUKNYBJehqwa+i7FcFQHsCk3TdhR8MT1ZR23uA==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.758.0.tgz",
"integrity": "sha512-XGguXhBqiCXMXRxcfCAVPlMbm3VyJTou79r/3mxWddHWF0XbhaQiBIbUz6vobVTD25YQRbWSmSch7VA8kI5Lrw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.750.0",
"@aws-sdk/nested-clients": "3.750.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/nested-clients": "3.758.0",
"@aws-sdk/types": "3.734.0",
"@smithy/property-provider": "^4.0.1",
"@smithy/types": "^4.1.0",
@@ -829,22 +829,22 @@
}
},
"node_modules/@aws-sdk/middleware-flexible-checksums": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.750.0.tgz",
"integrity": "sha512-ach0d2buDnX2TUausUbiXXFWFo3IegLnCrA+Rw8I9AYVpLN9lTaRwAYJwYC6zEuW9Golff8MwkYsp/OaC5tKMw==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.758.0.tgz",
"integrity": "sha512-o8Rk71S08YTKLoSobucjnbj97OCGaXgpEDNKXpXaavUM5xLNoHCLSUPRCiEN86Ivqxg1n17Y2nSRhfbsveOXXA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/crc32": "5.2.0",
"@aws-crypto/crc32c": "5.2.0",
"@aws-crypto/util": "5.2.0",
"@aws-sdk/core": "3.750.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/types": "3.734.0",
"@smithy/is-array-buffer": "^4.0.0",
"@smithy/node-config-provider": "^4.0.1",
"@smithy/protocol-http": "^5.0.1",
"@smithy/types": "^4.1.0",
"@smithy/util-middleware": "^4.0.1",
"@smithy/util-stream": "^4.1.1",
"@smithy/util-stream": "^4.1.2",
"@smithy/util-utf8": "^4.0.0",
"tslib": "^2.6.2"
},
@@ -911,23 +911,23 @@
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.750.0.tgz",
"integrity": "sha512-3H6Z46cmAQCHQ0z8mm7/cftY5ifiLfCjbObrbyyp2fhQs9zk6gCKzIX8Zjhw0RMd93FZi3ebRuKJWmMglf4Itw==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.758.0.tgz",
"integrity": "sha512-6mJ2zyyHPYSV6bAcaFpsdoXZJeQlR1QgBnZZ6juY/+dcYiuyWCdyLUbGzSZSE7GTfx6i+9+QWFeoIMlWKgU63A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.750.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/types": "3.734.0",
"@aws-sdk/util-arn-parser": "3.723.0",
"@smithy/core": "^3.1.4",
"@smithy/core": "^3.1.5",
"@smithy/node-config-provider": "^4.0.1",
"@smithy/protocol-http": "^5.0.1",
"@smithy/signature-v4": "^5.0.1",
"@smithy/smithy-client": "^4.1.5",
"@smithy/smithy-client": "^4.1.6",
"@smithy/types": "^4.1.0",
"@smithy/util-config-provider": "^4.0.0",
"@smithy/util-middleware": "^4.0.1",
"@smithy/util-stream": "^4.1.1",
"@smithy/util-stream": "^4.1.2",
"@smithy/util-utf8": "^4.0.0",
"tslib": "^2.6.2"
},
@@ -950,15 +950,15 @@
}
},
"node_modules/@aws-sdk/middleware-user-agent": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.750.0.tgz",
"integrity": "sha512-YYcslDsP5+2NZoN3UwuhZGkhAHPSli7HlJHBafBrvjGV/I9f8FuOO1d1ebxGdEP4HyRXUGyh+7Ur4q+Psk0ryw==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.758.0.tgz",
"integrity": "sha512-iNyehQXtQlj69JCgfaOssgZD4HeYGOwxcaKeG6F+40cwBjTAi0+Ph1yfDwqk2qiBPIRWJ/9l2LodZbxiBqgrwg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.750.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/types": "3.734.0",
"@aws-sdk/util-endpoints": "3.743.0",
"@smithy/core": "^3.1.4",
"@smithy/core": "^3.1.5",
"@smithy/protocol-http": "^5.0.1",
"@smithy/types": "^4.1.0",
"tslib": "^2.6.2"
@@ -968,44 +968,44 @@
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.750.0.tgz",
"integrity": "sha512-OH68BRF0rt9nDloq4zsfeHI0G21lj11a66qosaljtEP66PWm7tQ06feKbFkXHT5E1K3QhJW3nVyK8v2fEBY5fg==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.758.0.tgz",
"integrity": "sha512-YZ5s7PSvyF3Mt2h1EQulCG93uybprNGbBkPmVuy/HMMfbFTt4iL3SbKjxqvOZelm86epFfj7pvK7FliI2WOEcg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.750.0",
"@aws-sdk/core": "3.758.0",
"@aws-sdk/middleware-host-header": "3.734.0",
"@aws-sdk/middleware-logger": "3.734.0",
"@aws-sdk/middleware-recursion-detection": "3.734.0",
"@aws-sdk/middleware-user-agent": "3.750.0",
"@aws-sdk/middleware-user-agent": "3.758.0",
"@aws-sdk/region-config-resolver": "3.734.0",
"@aws-sdk/types": "3.734.0",
"@aws-sdk/util-endpoints": "3.743.0",
"@aws-sdk/util-user-agent-browser": "3.734.0",
"@aws-sdk/util-user-agent-node": "3.750.0",
"@aws-sdk/util-user-agent-node": "3.758.0",
"@smithy/config-resolver": "^4.0.1",
"@smithy/core": "^3.1.4",
"@smithy/core": "^3.1.5",
"@smithy/fetch-http-handler": "^5.0.1",
"@smithy/hash-node": "^4.0.1",
"@smithy/invalid-dependency": "^4.0.1",
"@smithy/middleware-content-length": "^4.0.1",
"@smithy/middleware-endpoint": "^4.0.5",
"@smithy/middleware-retry": "^4.0.6",
"@smithy/middleware-endpoint": "^4.0.6",
"@smithy/middleware-retry": "^4.0.7",
"@smithy/middleware-serde": "^4.0.2",
"@smithy/middleware-stack": "^4.0.1",
"@smithy/node-config-provider": "^4.0.1",
"@smithy/node-http-handler": "^4.0.2",
"@smithy/node-http-handler": "^4.0.3",
"@smithy/protocol-http": "^5.0.1",
"@smithy/smithy-client": "^4.1.5",
"@smithy/smithy-client": "^4.1.6",
"@smithy/types": "^4.1.0",
"@smithy/url-parser": "^4.0.1",
"@smithy/util-base64": "^4.0.0",
"@smithy/util-body-length-browser": "^4.0.0",
"@smithy/util-body-length-node": "^4.0.0",
"@smithy/util-defaults-mode-browser": "^4.0.6",
"@smithy/util-defaults-mode-node": "^4.0.6",
"@smithy/util-defaults-mode-browser": "^4.0.7",
"@smithy/util-defaults-mode-node": "^4.0.7",
"@smithy/util-endpoints": "^3.0.1",
"@smithy/util-middleware": "^4.0.1",
"@smithy/util-retry": "^4.0.1",
@@ -1034,12 +1034,12 @@
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.750.0.tgz",
"integrity": "sha512-RA9hv1Irro/CrdPcOEXKwJ0DJYJwYCsauGEdRXihrRfy8MNSR9E+mD5/Fr5Rxjaq5AHM05DYnN3mg/DU6VwzSw==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.758.0.tgz",
"integrity": "sha512-0RPCo8fYJcrenJ6bRtiUbFOSgQ1CX/GpvwtLU2Fam1tS9h2klKK8d74caeV6A1mIUvBU7bhyQ0wMGlwMtn3EYw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/middleware-sdk-s3": "3.750.0",
"@aws-sdk/middleware-sdk-s3": "3.758.0",
"@aws-sdk/types": "3.734.0",
"@smithy/protocol-http": "^5.0.1",
"@smithy/signature-v4": "^5.0.1",
@@ -1051,12 +1051,12 @@
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.750.0.tgz",
"integrity": "sha512-X/KzqZw41iWolwNdc8e3RMcNSMR364viHv78u6AefXOO5eRM40c4/LuST1jDzq35/LpnqRhL7/MuixOetw+sFw==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.758.0.tgz",
"integrity": "sha512-ckptN1tNrIfQUaGWm/ayW1ddG+imbKN7HHhjFdS4VfItsP0QQOB0+Ov+tpgb4MoNR4JaUghMIVStjIeHN2ks1w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/nested-clients": "3.750.0",
"@aws-sdk/nested-clients": "3.758.0",
"@aws-sdk/types": "3.734.0",
"@smithy/property-provider": "^4.0.1",
"@smithy/shared-ini-file-loader": "^4.0.1",
@@ -1132,12 +1132,12 @@
}
},
"node_modules/@aws-sdk/util-user-agent-node": {
"version": "3.750.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.750.0.tgz",
"integrity": "sha512-84HJj9G9zbrHX2opLk9eHfDceB+UIHVrmflMzWHpsmo9fDuro/flIBqaVDlE021Osj6qIM0SJJcnL6s23j7JEw==",
"version": "3.758.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.758.0.tgz",
"integrity": "sha512-A5EZw85V6WhoKMV2hbuFRvb9NPlxEErb4HPO6/SPXYY4QrjprIzScHxikqcWv1w4J3apB1wto9LPU3IMsYtfrw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/middleware-user-agent": "3.750.0",
"@aws-sdk/middleware-user-agent": "3.758.0",
"@aws-sdk/types": "3.734.0",
"@smithy/node-config-provider": "^4.0.1",
"@smithy/types": "^4.1.0",
@@ -2438,9 +2438,9 @@
}
},
"node_modules/@smithy/core": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.1.4.tgz",
"integrity": "sha512-wFExFGK+7r2wYriOqe7RRIBNpvxwiS95ih09+GSLRBdoyK/O1uZA7K7pKesj5CBvwJuSBeXwLyR88WwIAY+DGA==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.1.5.tgz",
"integrity": "sha512-HLclGWPkCsekQgsyzxLhCQLa8THWXtB5PxyYN+2O6nkyLt550KQKTlbV2D1/j5dNIQapAZM1+qFnpBFxZQkgCA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/middleware-serde": "^4.0.2",
@@ -2448,7 +2448,7 @@
"@smithy/types": "^4.1.0",
"@smithy/util-body-length-browser": "^4.0.0",
"@smithy/util-middleware": "^4.0.1",
"@smithy/util-stream": "^4.1.1",
"@smithy/util-stream": "^4.1.2",
"@smithy/util-utf8": "^4.0.0",
"tslib": "^2.6.2"
},
@@ -2656,12 +2656,12 @@
}
},
"node_modules/@smithy/middleware-endpoint": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.0.5.tgz",
"integrity": "sha512-cPzGZV7qStHwboFrm6GfrzQE+YDiCzWcTh4+7wKrP/ZQ4gkw+r7qDjV8GjM4N0UYsuUyLfpzLGg5hxsYTU11WA==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.0.6.tgz",
"integrity": "sha512-ftpmkTHIFqgaFugcjzLZv3kzPEFsBFSnq1JsIkr2mwFzCraZVhQk2gqN51OOeRxqhbPTkRFj39Qd2V91E/mQxg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.1.4",
"@smithy/core": "^3.1.5",
"@smithy/middleware-serde": "^4.0.2",
"@smithy/node-config-provider": "^4.0.1",
"@smithy/shared-ini-file-loader": "^4.0.1",
@@ -2675,15 +2675,15 @@
}
},
"node_modules/@smithy/middleware-retry": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.0.6.tgz",
"integrity": "sha512-s8QzuOQnbdvRymD9Gt9c9zMq10wUQAHQ3z72uirrBHCwZcLTrL5iCOuVTMdka2IXOYhQE890WD5t6G24+F+Qcg==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.0.7.tgz",
"integrity": "sha512-58j9XbUPLkqAcV1kHzVX/kAR16GT+j7DUZJqwzsxh1jtz7G82caZiGyyFgUvogVfNTg3TeAOIJepGc8TXF4AVQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/node-config-provider": "^4.0.1",
"@smithy/protocol-http": "^5.0.1",
"@smithy/service-error-classification": "^4.0.1",
"@smithy/smithy-client": "^4.1.5",
"@smithy/smithy-client": "^4.1.6",
"@smithy/types": "^4.1.0",
"@smithy/util-middleware": "^4.0.1",
"@smithy/util-retry": "^4.0.1",
@@ -2749,9 +2749,9 @@
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.0.2.tgz",
"integrity": "sha512-X66H9aah9hisLLSnGuzRYba6vckuFtGE+a5DcHLliI/YlqKrGoxhisD5XbX44KyoeRzoNlGr94eTsMVHFAzPOw==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.0.3.tgz",
"integrity": "sha512-dYCLeINNbYdvmMLtW0VdhW1biXt+PPCGazzT5ZjKw46mOtdgToQEwjqZSS9/EN8+tNs/RO0cEWG044+YZs97aA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/abort-controller": "^4.0.1",
@@ -2862,17 +2862,17 @@
}
},
"node_modules/@smithy/smithy-client": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.1.5.tgz",
"integrity": "sha512-DMXYoYeL4QkElr216n1yodTFeATbfb4jwYM9gKn71Rw/FNA1/Sm36tkTSCsZEs7mgpG3OINmkxL9vgVFzyGPaw==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.1.6.tgz",
"integrity": "sha512-UYDolNg6h2O0L+cJjtgSyKKvEKCOa/8FHYJnBobyeoeWDmNpXjwOAtw16ezyeu1ETuuLEOZbrynK0ZY1Lx9Jbw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.1.4",
"@smithy/middleware-endpoint": "^4.0.5",
"@smithy/core": "^3.1.5",
"@smithy/middleware-endpoint": "^4.0.6",
"@smithy/middleware-stack": "^4.0.1",
"@smithy/protocol-http": "^5.0.1",
"@smithy/types": "^4.1.0",
"@smithy/util-stream": "^4.1.1",
"@smithy/util-stream": "^4.1.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -2969,13 +2969,13 @@
}
},
"node_modules/@smithy/util-defaults-mode-browser": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.6.tgz",
"integrity": "sha512-N8+VCt+piupH1A7DgSVDNrVHqRLz8r6DvBkpS7EWHiIxsUk4jqGuQLjqC/gnCzmwGkVBdNruHoYAzzaSQ8e80w==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.7.tgz",
"integrity": "sha512-CZgDDrYHLv0RUElOsmZtAnp1pIjwDVCSuZWOPhIOBvG36RDfX1Q9+6lS61xBf+qqvHoqRjHxgINeQz47cYFC2Q==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/property-provider": "^4.0.1",
"@smithy/smithy-client": "^4.1.5",
"@smithy/smithy-client": "^4.1.6",
"@smithy/types": "^4.1.0",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
@@ -2985,16 +2985,16 @@
}
},
"node_modules/@smithy/util-defaults-mode-node": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.6.tgz",
"integrity": "sha512-9zhx1shd1VwSSVvLZB8CM3qQ3RPD3le7A3h/UPuyh/PC7g4OaWDi2xUNzamsVoSmCGtmUBONl56lM2EU6LcH7A==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.7.tgz",
"integrity": "sha512-79fQW3hnfCdrfIi1soPbK3zmooRFnLpSx3Vxi6nUlqaaQeC5dm8plt4OTNDNqEEEDkvKghZSaoti684dQFVrGQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/config-resolver": "^4.0.1",
"@smithy/credential-provider-imds": "^4.0.1",
"@smithy/node-config-provider": "^4.0.1",
"@smithy/property-provider": "^4.0.1",
"@smithy/smithy-client": "^4.1.5",
"@smithy/smithy-client": "^4.1.6",
"@smithy/types": "^4.1.0",
"tslib": "^2.6.2"
},
@@ -3056,13 +3056,13 @@
}
},
"node_modules/@smithy/util-stream": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.1.1.tgz",
"integrity": "sha512-+Xvh8nhy0Wjv1y71rBVyV3eJU3356XsFQNI8dEZVNrQju7Eib8G31GWtO+zMa9kTCGd41Mflu+ZKfmQL/o2XzQ==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.1.2.tgz",
"integrity": "sha512-44PKEqQ303d3rlQuiDpcCcu//hV8sn+u2JBo84dWCE0rvgeiVl0IlLMagbU++o0jCWhYCsHaAt9wZuZqNe05Hw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/fetch-http-handler": "^5.0.1",
"@smithy/node-http-handler": "^4.0.2",
"@smithy/node-http-handler": "^4.0.3",
"@smithy/types": "^4.1.0",
"@smithy/util-base64": "^4.0.0",
"@smithy/util-buffer-from": "^4.0.0",
@@ -4861,9 +4861,9 @@
}
},
"node_modules/dd-trace": {
"version": "5.39.0",
"resolved": "https://registry.npmjs.org/dd-trace/-/dd-trace-5.39.0.tgz",
"integrity": "sha512-vNC25L2ScHxGl9DdmmbwgrApd2gDlp4vA84N/hLq0MFWpBkia7BbhfA2GGtJ8+4vUChFdoefLMZhMKd7WAM7AA==",
"version": "5.40.0",
"resolved": "https://registry.npmjs.org/dd-trace/-/dd-trace-5.40.0.tgz",
"integrity": "sha512-/UYVCcgpZ9LnnUvIJcNfd1Hj51i8HhqLOn9PCj5gK3wJUn6MY/ie/5da2ZaFtoK2DKQ9OZmFBITLV3+KDl4pjA==",
"hasInstallScript": true,
"license": "(Apache-2.0 OR BSD-3-Clause)",
"dependencies": {
@@ -7738,13 +7738,14 @@
}
},
"node_modules/juice": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/juice/-/juice-11.0.0.tgz",
"integrity": "sha512-sGF8hPz9/Wg+YXbaNDqc1Iuoaw+J/P9lBHNQKXAGc9pPNjCd4fyPai0Zxj7MRtdjMr0lcgk5PjEIkP2b8R9F3w==",
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/juice/-/juice-11.0.1.tgz",
"integrity": "sha512-R3KLud4l/sN9AMmFZs0QY7cugGSiKvPhGyIsufCV5nJ0MjSlngUE7k80TmFeK9I62wOXrjWBtYA1knVs2OkF8w==",
"license": "MIT",
"dependencies": {
"cheerio": "^1.0.0",
"commander": "^12.1.0",
"entities": "^4.5.0",
"mensch": "^0.3.4",
"slick": "^1.12.2",
"web-resource-inliner": "^7.0.0"
@@ -8975,9 +8976,9 @@
}
},
"node_modules/prettier": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz",
"integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==",
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {
@@ -9919,9 +9920,9 @@
}
},
"node_modules/soap": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/soap/-/soap-1.1.8.tgz",
"integrity": "sha512-fDNGyGsPkQP3bZX/366Ud5Kpjo9mCMh7ZKYIc3uipBEPPM2ZqCNkv1Z2/w0qpzpYFLL7do8WWwVUAjAwuUe1AQ==",
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/soap/-/soap-1.1.9.tgz",
"integrity": "sha512-x6wMhwIwGFnMQiV0tLIygERELwpV/EkidUvzjcCPRx0D16YngNL8z7j5+nFad0Fl5irisXbfY2FKzvF9SEjMog==",
"license": "MIT",
"dependencies": {
"axios": "^1.7.9",

View File

@@ -19,12 +19,12 @@
"makeitpretty": "prettier --write \"**/*.{css,js,json,jsx,scss}\""
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.750.0",
"@aws-sdk/client-elasticache": "^3.755.0",
"@aws-sdk/client-s3": "^3.750.0",
"@aws-sdk/client-secrets-manager": "^3.750.0",
"@aws-sdk/client-ses": "^3.750.0",
"@aws-sdk/credential-provider-node": "^3.750.0",
"@aws-sdk/client-cloudwatch-logs": "^3.758.0",
"@aws-sdk/client-elasticache": "^3.758.0",
"@aws-sdk/client-s3": "^3.758.0",
"@aws-sdk/client-secrets-manager": "^3.758.0",
"@aws-sdk/client-ses": "^3.758.0",
"@aws-sdk/credential-provider-node": "^3.758.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
@@ -42,7 +42,7 @@
"cors": "2.8.5",
"crisp-status-reporter": "^1.2.2",
"csrf": "^3.1.0",
"dd-trace": "^5.39.0",
"dd-trace": "^5.40.0",
"dinero.js": "^1.9.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
@@ -53,7 +53,7 @@
"intuit-oauth": "^4.2.0",
"ioredis": "^5.5.0",
"json-2-csv": "^5.5.8",
"juice": "^11.0.0",
"juice": "^11.0.1",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"moment-timezone": "^0.5.47",
@@ -66,7 +66,7 @@
"redis": "^4.7.0",
"rimraf": "^6.0.1",
"skia-canvas": "^2.0.2",
"soap": "^1.1.8",
"soap": "^1.1.9",
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5",
"ssh2-sftp-client": "^11.0.0",
@@ -85,7 +85,7 @@
"eslint-plugin-react": "^7.37.4",
"globals": "^15.15.0",
"p-limit": "^3.1.0",
"prettier": "^3.5.2",
"prettier": "^3.5.3",
"source-map-explorer": "^2.5.2"
}
}

131
server.js
View File

@@ -22,20 +22,24 @@ const cookieParser = require("cookie-parser");
const { Server } = require("socket.io");
const { createAdapter } = require("@socket.io/redis-adapter");
const { instrument } = require("@socket.io/admin-ui");
const { isString, isEmpty } = require("lodash");
const { isString, isEmpty, isFunction } = require("lodash");
const logger = require("./server/utils/logger");
const { applyRedisHelpers } = require("./server/utils/redisHelpers");
const { applyIOHelpers } = require("./server/utils/ioHelpers");
const { redisSocketEvents } = require("./server/web-sockets/redisSocketEvents");
const { ElastiCacheClient, DescribeCacheClustersCommand } = require("@aws-sdk/client-elasticache");
const {
ElastiCacheClient,
DescribeCacheClustersCommand,
DescribeReplicationGroupsCommand
} = require("@aws-sdk/client-elasticache");
const { InstanceRegion } = require("./server/utils/instanceMgr");
const StartStatusReporter = require("./server/utils/statusReporter");
const { registerCleanupTask, initializeCleanupManager } = require("./server/utils/cleanupManager");
const { loadEmailQueue } = require("./server/notifications/queues/emailQueue");
const { loadAppQueue } = require("./server/notifications/queues/appQueue");
const cleanupTasks = [];
let isShuttingDown = false;
const CLUSTER_RETRY_BASE_DELAY = 100;
const CLUSTER_RETRY_MAX_DELAY = 5000;
const CLUSTER_RETRY_JITTER = 100;
@@ -126,26 +130,48 @@ const applyRoutes = ({ app }) => {
* @returns {Promise<string[]>}
*/
const getRedisNodesFromAWS = async () => {
const client = new ElastiCacheClient({
region: InstanceRegion()
});
const params = {
ReplicationGroupId: process.env.REDIS_CLUSTER_ID,
ShowCacheNodeInfo: true
};
const client = new ElastiCacheClient({ region: InstanceRegion() });
try {
// Fetch the cache clusters associated with the replication group
const command = new DescribeCacheClustersCommand(params);
const response = await client.send(command);
const cacheClusters = response.CacheClusters;
const describeReplicationGroupCommand = new DescribeReplicationGroupsCommand({
ReplicationGroupId: process.env.REDIS_CLUSTER_ID
});
const describeReplicationGroupResponse = await client.send(describeReplicationGroupCommand);
return cacheClusters.flatMap((cluster) =>
cluster.CacheNodes.map((node) => `${node.Endpoint.Address}:${node.Endpoint.Port}`)
);
//TODO: add checking to make sure there's only 1.
const cacheClusterIds = describeReplicationGroupResponse.ReplicationGroups[0].MemberClusters;
// Ensure cacheClusters exists and is an array
if (!cacheClusterIds || !Array.isArray(cacheClusterIds) || cacheClusterIds.length === 0) {
logger.log(`No cache clusters found for cluster id ${process.env.REDIS_CLUSTER_ID}`, "ERROR", "redis", "api");
return [];
}
const nodeEndpointAddresses = [];
for (const cluster of cacheClusterIds) {
const params = { CacheClusterId: cluster, ShowCacheNodeInfo: true };
const command = new DescribeCacheClustersCommand(params);
const response = await client.send(command);
if (response.CacheClusters && Array.isArray(response.CacheClusters)) {
// Map nodes to address strings
//TODO: What happens if we have more shards?
const nodeAddress = `${response.CacheClusters[0].CacheNodes[0].Endpoint.Address}:${response.CacheClusters[0].CacheNodes[0].Endpoint.Port}`;
// Debug log node addresses
logger.log(`Cluster node addresses: ${nodeAddress}`, "DEBUG", "redis", "api");
// Return only those addresses that start with the current cluster id
nodeEndpointAddresses.push(nodeAddress);
}
}
return nodeEndpointAddresses;
// Process each cluster
} catch (err) {
logger.log(`Error fetching Redis nodes from AWS: ${err.message}`, "ERROR", "redis", "api");
logger.log(`Error fetching Redis nodes from AWS:`, "ERROR", "redis", "api", {
message: err?.message,
stack: err?.stack
});
throw err;
}
};
@@ -169,7 +195,10 @@ const connectToRedisCluster = async () => {
try {
redisServers = JSON.parse(process.env.REDIS_URL);
} catch (error) {
logger.log(`Failed to parse REDIS_URL: ${error.message}. Exiting...`, "ERROR", "redis", "api");
logger.log(`Failed to parse REDIS_URL: ${error.message}. Exiting...`, "ERROR", "redis", "api", {
message: error?.message,
stack: error?.stack
});
process.exit(1);
}
}
@@ -207,7 +236,10 @@ const connectToRedisCluster = async () => {
});
redisCluster.on("error", (err) => {
logger.log(`Redis cluster connection failed: ${err.message}`, "ERROR", "redis", "api");
logger.log(`Redis cluster connection failed:`, "ERROR", "redis", "api", {
message: err?.message,
stack: err?.stack
});
reject(err);
});
});
@@ -229,8 +261,18 @@ const applySocketIO = async ({ server, app }) => {
const pubClient = redisCluster;
const subClient = pubClient.duplicate();
pubClient.on("error", (err) => logger.log(`Redis pubClient error: ${err}`, "ERROR", "redis"));
subClient.on("error", (err) => logger.log(`Redis subClient error: ${err}`, "ERROR", "redis"));
pubClient.on("error", (err) =>
logger.log(`Redis pubClient error: ${err}`, "ERROR", "redis", "api", {
message: err?.message,
stack: err?.stack
})
);
subClient.on("error", (err) =>
logger.log(`Redis subClient error: ${err}`, "ERROR", "redis", "api", {
message: err?.message,
stack: err?.stack
})
);
// Register Redis cleanup
registerCleanupTask(async () => {
@@ -332,6 +374,9 @@ const main = async () => {
const server = http.createServer(app);
// Initialize cleanup manager with signal handlers
initializeCleanupManager();
const { pubClient, ioRedis } = await applySocketIO({ server, app });
const redisHelpers = applyRedisHelpers({ pubClient, app, logger });
const ioHelpers = applyIOHelpers({ app, redisHelpers, ioRedis, logger });
@@ -348,13 +393,11 @@ const main = async () => {
const StatusReporter = StartStatusReporter();
registerCleanupTask(async () => {
StatusReporter.end();
if (isFunction(StatusReporter?.end)) {
StatusReporter.end();
}
});
// Add SIGTERM signal handler
process.on("SIGTERM", handleSigterm);
process.on("SIGINT", handleSigterm); // Optional: Handle Ctrl+C
try {
await server.listen(port);
logger.log(`Server started on port ${port}`, "INFO", "api");
@@ -373,33 +416,3 @@ main().catch((error) => {
// Note: If we want the app to crash on all uncaught async operations, we would
// need to put a `process.exit(1);` here
});
// Register a cleanup task
function registerCleanupTask(task) {
cleanupTasks.push(task);
}
// SIGTERM handler
async function handleSigterm() {
if (isShuttingDown) {
logger.log("sigterm-api", "WARN", null, null, { message: "Shutdown already in progress, ignoring signal." });
return;
}
isShuttingDown = true;
logger.log("sigterm-api", "WARN", null, null, { message: "SIGTERM Received. Starting graceful shutdown." });
try {
for (const task of cleanupTasks) {
logger.log("sigterm-api", "WARN", null, null, { message: `Running cleanup task: ${task.name}` });
await task();
}
logger.log("sigterm-api", "WARN", null, null, { message: `All cleanup tasks completed.` });
} catch (error) {
logger.log("sigterm-api-error", "ERROR", null, null, { message: error.message, stack: error.stack });
}
process.exit(0);
}

View File

@@ -20,6 +20,11 @@ const defaultFooter = () => {
const now = () => moment().format("MM/DD/YYYY @ hh:mm a");
/**
* Generate the email template
* @param strings
* @returns {string}
*/
const generateEmailTemplate = (strings) => {
return (
`

View File

@@ -69,11 +69,14 @@ const sendServerEmail = async ({ subject, text }) => {
}
},
(err, info) => {
logger.log("server-email-failure", err ? "error" : "debug", null, null, { message: err?.message });
logger.log("server-email-failure", err ? "error" : "debug", null, null, {
message: err?.message,
stack: err?.stack
});
}
);
} catch (error) {
logger.log("server-email-failure", "error", null, null, { message: error?.message });
logger.log("server-email-failure", "error", null, null, { message: error?.message, stack: error?.stack });
}
};
@@ -92,11 +95,11 @@ const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachmen
},
(err, info) => {
// (message, type, user, record, meta
logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message });
logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message, stack: err?.stack });
}
);
} catch (error) {
logger.log("server-email-failure", "error", null, null, { message: error?.message });
logger.log("server-email-failure", "error", null, null, { message: error?.message, stack: error?.stack });
}
};
@@ -125,7 +128,8 @@ const sendEmail = async (req, res) => {
cc: req.body.cc,
subject: req.body.subject,
templateStrings: req.body.templateStrings,
errorMessage: error?.message
errorMessage: error?.message,
errorStack: error?.stack
});
}
})
@@ -194,7 +198,8 @@ const sendEmail = async (req, res) => {
cc: req.body.cc,
subject: req.body.subject,
templateStrings: req.body.templateStrings,
errorMessage: err?.message
errorMessage: err?.message,
errorStack: err?.stack
});
logEmail(req, {
to: req.body.to,
@@ -202,7 +207,7 @@ const sendEmail = async (req, res) => {
subject: req.body.subject,
bodyshopid: req.body.bodyshopid
});
res.status(500).json({ success: false, errorMessage: err?.message });
res.status(500).json({ success: false, errorMessage: err?.message, stack: err?.stack });
}
}
);
@@ -270,14 +275,16 @@ ${body.bounce?.bouncedRecipients.map(
},
(err, info) => {
logger.log("sns-error", err ? "error" : "debug", "api", null, {
errorMessage: err?.message
errorMessage: err?.message,
errorStack: err?.stack
});
}
);
}
} catch (error) {
logger.log("sns-error", "ERROR", "api", null, {
errorMessage: error?.message
errorMessage: error?.message,
errorStack: error?.stack
});
}
res.sendStatus(200);

View File

@@ -11,6 +11,7 @@ const moment = require("moment-timezone");
const { taskEmailQueue } = require("./tasksEmailsQueue");
const mailer = require("./mailer");
const { InstanceEndpoints } = require("../utils/instanceMgr");
const { formatTaskPriority } = require("../notifications/stringHelpers");
// Initialize the Tasks Email Queue
const tasksEmailQueue = taskEmailQueue();
@@ -62,16 +63,6 @@ const formatDate = (date) => {
return date ? `| Due on: ${moment(date).format("MM/DD/YYYY")}` : "";
};
const formatPriority = (priority) => {
if (priority === 1) {
return "High";
} else if (priority === 3) {
return "Low";
} else {
return "Medium";
}
};
/**
* Generate the email template arguments.
* @param title
@@ -88,7 +79,7 @@ const generateTemplateArgs = (title, priority, description, dueDate, bodyshop, j
const endPoints = InstanceEndpoints();
return {
header: title,
subHeader: `Body Shop: ${bodyshop.shopname} | Priority: ${formatPriority(priority)} ${formatDate(dueDate)} | Created By: ${createdBy || "N/A"}`,
subHeader: `Body Shop: ${bodyshop.shopname} | Priority: ${formatTaskPriority(priority)} ${formatDate(dueDate)} | Created By: ${createdBy || "N/A"}`,
body: `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()}<br>${description ? description.concat("<br>") : ""}<a href="${endPoints}/manage/tasks/alltasks?taskid=${taskId}">View this task.</a>`,
dateLine
};
@@ -155,7 +146,7 @@ const taskAssignedEmail = async (req, res) => {
sendMail(
"assigned",
tasks_by_pk.assigned_to_employee.user_email,
`A ${formatPriority(newTask.priority)} priority task has been ${dirty ? "reassigned to" : "created for"} you - ${newTask.title}`,
`A ${formatTaskPriority(newTask.priority)} priority task has been ${dirty ? "reassigned to" : "created for"} you - ${newTask.title}`,
generateEmailTemplate(
generateTemplateArgs(
newTask.title,
@@ -239,7 +230,7 @@ const tasksRemindEmail = async (req, res) => {
const onlyTask = groupedTasks[recipient.email][0];
emailData.subject =
`New ${formatPriority(onlyTask.priority)} Priority Task Reminder - ${onlyTask.title} ${onlyTask.due_date ? `- ${formatDate(onlyTask.due_date)}` : ""}`.trim();
`New ${formatTaskPriority(onlyTask.priority)} Priority Task Reminder - ${onlyTask.title} ${onlyTask.due_date ? `- ${formatDate(onlyTask.due_date)}` : ""}`.trim();
emailData.html = generateEmailTemplate(
generateTemplateArgs(
@@ -266,7 +257,7 @@ const tasksRemindEmail = async (req, res) => {
body: `<ul>
${allTasks
.map((task) =>
`<li><a href="${InstanceEndpoints()}/manage/tasks/alltasks?taskid=${task.id}">${task.title} - Priority: ${formatPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a></li>`.trim()
`<li><a href="${InstanceEndpoints()}/manage/tasks/alltasks?taskid=${task.id}">${task.title} - Priority: ${formatTaskPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a></li>`.trim()
)
.join("")}
</ul>`

View File

@@ -2708,16 +2708,14 @@ exports.INSERT_AUDIT_TRAIL = `
exports.GET_JOB_WATCHERS = `
query GET_JOB_WATCHERS($jobid: uuid!) {
job_watchers_aggregate(where: { jobid: { _eq: $jobid } }) {
nodes {
user_email
user {
authid
employee {
id
first_name
last_name
}
job_watchers(where: { jobid: { _eq: $jobid } }) {
user_email
user {
authid
employee {
id
first_name
last_name
}
}
}
@@ -2728,6 +2726,7 @@ query GET_JOB_WATCHERS($jobid: uuid!) {
bodyshop {
id
shopname
timezone
}
}
}
@@ -2759,3 +2758,14 @@ exports.INSERT_NOTIFICATIONS_MUTATION = ` mutation INSERT_NOTIFICATIONS($object
}
}
}`;
// REMEMBER: Update the cache_bodyshop event in hasura to include any added fields
exports.GET_BODYSHOP_BY_ID = `
query GET_BODYSHOP_BY_ID($id: uuid!) {
bodyshops_by_pk(id: $id) {
id
md_order_statuses
shopname
}
}
`;

View File

@@ -22,7 +22,7 @@ async function processNotificationEvent(req, res, parserPath, successMessage) {
// Call scenarioParser but don't await it; log any error that occurs.
scenarioParser(req, parserPath).catch((error) => {
logger.log("notifications-error", "error", "notifications", null, { error: error?.message });
logger.log("notifications-error", "error", "notifications", null, { message: error?.message, stack: error?.stack });
});
return res.status(200).json({ message: successMessage });
@@ -50,13 +50,69 @@ const handleBillsChange = async (req, res) =>
/**
* Handle documents change notifications.
* Processes both old and new job IDs if the document was moved between jobs.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Promise<Object>} JSON response with a success message.
*/
const handleDocumentsChange = async (req, res) =>
processNotificationEvent(req, res, "req.body.event.new.jobid", "Documents Change Notifications Event Handled.");
const handleDocumentsChange = async (req, res) => {
const { logger } = req;
const newJobId = req.body?.event?.data?.new?.jobid;
const oldJobId = req.body?.event?.data?.old?.jobid;
// If jobid changed (document moved between jobs), we need to notify both jobs
if (oldJobId && newJobId && oldJobId !== newJobId) {
// Process notification for new job ID
scenarioParser(req, "req.body.event.new.jobid").catch((error) => {
logger.log("notifications-error", "error", "notifications", null, {
message: error?.message,
stack: error?.stack
});
});
// Create a modified request for old job ID
const oldJobReq = {
body: {
...req.body,
event: {
...req.body.event,
data: {
new: {
...req.body.event.data.old,
// Add a flag to indicate this document was moved away
_documentMoved: true,
_movedToJob: newJobId
},
old: null
}
}
},
logger,
sessionUtils: req.sessionUtils
};
// Process notification for old job ID using the modified request
scenarioParser(oldJobReq, "req.body.event.new.jobid").catch((error) => {
logger.log("notifications-error", "error", "notifications", null, {
message: error?.message,
stack: error?.stack
});
});
return res.status(200).json({ message: "Documents Change Notifications Event Handled for both jobs." });
}
// Otherwise just process the new job ID
scenarioParser(req, "req.body.event.new.jobid").catch((error) => {
logger.log("notifications-error", "error", "notifications", null, {
message: error?.message,
stack: error?.stack
});
});
return res.status(200).json({ message: "Documents Change Notifications Event Handled." });
};
/**
* Handle job lines change notifications.
@@ -78,24 +134,6 @@ const handleJobLinesChange = async (req, res) =>
const handleNotesChange = async (req, res) =>
processNotificationEvent(req, res, "req.body.event.new.jobid", "Notes Changed Notification Event Handled.");
/**
* Handle parts dispatch change notifications.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Object} JSON response with a success message.
*/
const handlePartsDispatchChange = (req, res) => res.status(200).json({ message: "Parts Dispatch change handled." });
/**
* Handle parts order change notifications.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Object} JSON response with a success message.
*/
const handlePartsOrderChange = (req, res) => res.status(200).json({ message: "Parts Order change handled." });
/**
* Handle payments change notifications.
*
@@ -126,6 +164,27 @@ const handleTasksChange = async (req, res) =>
const handleTimeTicketsChange = async (req, res) =>
processNotificationEvent(req, res, "req.body.event.new.jobid", "Time Tickets Changed Notification Event Handled.");
/**
* Handle parts dispatch change notifications.
* Note: Placeholder
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Object} JSON response with a success message.
*
*/
const handlePartsDispatchChange = (req, res) => res.status(200).json({ message: "Parts Dispatch change handled." });
/**
* Handle parts order change notifications.
* Note: Placeholder
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Object} JSON response with a success message.
*/
const handlePartsOrderChange = (req, res) => res.status(200).json({ message: "Parts Order change handled." });
module.exports = {
handleJobsChange,
handleBillsChange,

View File

@@ -3,15 +3,14 @@
*
* 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.
* and returns 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).
* @param {string} [options.jobId] - The job ID, if already extracted by the caller (optional).
* @returns {Object} An object containing the parsed event details:
* - {Array<string>} changedFieldNames - List of field names that have changed.
* - {Object} changedFields - Map of changed fields with their old and new values.
@@ -19,9 +18,9 @@
* - {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.
* - {string|null} jobId - The provided jobId or null if not provided.
*/
const eventParser = async ({ oldData, newData, trigger, table, jobIdField }) => {
const eventParser = async ({ oldData, newData, trigger, table, jobId = null }) => {
const isNew = !oldData; // True if no old data exists, indicating a new entry
let changedFields = {};
let changedFieldNames = [];
@@ -61,19 +60,6 @@ const eventParser = async ({ oldData, newData, trigger, table, 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, // Array of fields that changed
changedFields, // Object with old/new values for changed fields
@@ -81,7 +67,7 @@ const eventParser = async ({ oldData, newData, trigger, table, jobIdField }) =>
data: newData, // Current data state
trigger, // Event trigger (e.g., 'INSERT', 'UPDATE')
table, // Associated table name
jobId // Extracted jobId or null
jobId // Provided jobId or null
};
};

View File

@@ -1,12 +1,15 @@
const { Queue, Worker } = require("bullmq");
const { INSERT_NOTIFICATIONS_MUTATION } = require("../../graphql-client/queries");
const { registerCleanupTask } = require("../../utils/cleanupManager");
const getBullMQPrefix = require("../../utils/getBullMQPrefix");
const devDebugLogger = require("../../utils/devDebugLogger");
const graphQLClient = require("../../graphql-client/graphql-client").client;
// Base time-related constant in minutes, sourced from environment variable or defaulting to 1
const APP_CONSOLIDATION_DELAY_IN_MINS = (() => {
const envValue = process.env?.APP_CONSOLIDATION_DELAY_IN_MINS;
const parsedValue = envValue ? parseInt(envValue, 10) : NaN;
return isNaN(parsedValue) ? 1 : Math.max(1, parsedValue); // Default to 1, ensure at least 1
return isNaN(parsedValue) ? 3 : Math.max(1, parsedValue); // Default to 3, ensure at least 1
})();
// Base time-related constant (in milliseconds) / DO NOT TOUCH
@@ -44,17 +47,20 @@ const buildNotificationContent = (notifications) => {
*/
const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
if (!addQueue || !consolidateQueue) {
logger.logger.info("Initializing Notifications Queues");
const prefix = getBullMQPrefix();
const devKey = process.env?.NODE_ENV === "production" ? "prod" : "dev";
devDebugLogger(`Initializing Notifications Queues with prefix: ${prefix}`);
addQueue = new Queue("notificationsAdd", {
prefix,
connection: pubClient,
prefix: "{BULLMQ}",
defaultJobOptions: { removeOnComplete: true, removeOnFail: true }
});
consolidateQueue = new Queue("notificationsConsolidate", {
prefix,
connection: pubClient,
prefix: "{BULLMQ}",
defaultJobOptions: { removeOnComplete: true, removeOnFail: true }
});
@@ -62,9 +68,9 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
"notificationsAdd",
async (job) => {
const { jobId, key, variables, recipients, body, jobRoNumber } = job.data;
logger.logger.info(`Adding notifications for jobId ${jobId}`);
devDebugLogger(`Adding notifications for jobId ${jobId}`);
const redisKeyPrefix = `app:notifications:${jobId}`;
const redisKeyPrefix = `app:${devKey}:notifications:${jobId}`;
const notification = { key, variables, body, jobRoNumber, timestamp: Date.now() };
for (const recipient of recipients) {
@@ -74,12 +80,12 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
const notifications = existingNotifications ? JSON.parse(existingNotifications) : [];
notifications.push(notification);
await pubClient.set(userKey, JSON.stringify(notifications), "EX", NOTIFICATION_STORAGE_EXPIRATION / 1000);
logger.logger.debug(`Stored notification for ${user} under ${userKey}: ${JSON.stringify(notifications)}`);
devDebugLogger(`Stored notification for ${user} under ${userKey}: ${JSON.stringify(notifications)}`);
}
const consolidateKey = `app:consolidate:${jobId}`;
const consolidateKey = `app:${devKey}:consolidate:${jobId}`;
const flagSet = await pubClient.setnx(consolidateKey, "pending");
logger.logger.debug(`Consolidation flag set for jobId ${jobId}: ${flagSet}`);
devDebugLogger(`Consolidation flag set for jobId ${jobId}: ${flagSet}`);
if (flagSet) {
await consolidateQueue.add(
@@ -92,15 +98,15 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
backoff: LOCK_EXPIRATION
}
);
logger.logger.info(`Scheduled consolidation for jobId ${jobId}`);
devDebugLogger(`Scheduled consolidation for jobId ${jobId}`);
await pubClient.expire(consolidateKey, CONSOLIDATION_FLAG_EXPIRATION / 1000);
} else {
logger.logger.debug(`Consolidation already scheduled for jobId ${jobId}`);
devDebugLogger(`Consolidation already scheduled for jobId ${jobId}`);
}
},
{
prefix,
connection: pubClient,
prefix: "{BULLMQ}",
concurrency: 5
}
);
@@ -109,23 +115,24 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
"notificationsConsolidate",
async (job) => {
const { jobId, recipients } = job.data;
logger.logger.info(`Consolidating notifications for jobId ${jobId}`);
devDebugLogger(`Consolidating notifications for jobId ${jobId}`);
const redisKeyPrefix = `app:${devKey}:notifications:${jobId}`;
const lockKey = `lock:${devKey}:consolidate:${jobId}`;
const redisKeyPrefix = `app:notifications:${jobId}`;
const lockKey = `lock:consolidate:${jobId}`;
const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", LOCK_EXPIRATION / 1000);
logger.logger.debug(`Lock acquisition for jobId ${jobId}: ${lockAcquired}`);
devDebugLogger(`Lock acquisition for jobId ${jobId}: ${lockAcquired}`);
if (lockAcquired) {
try {
const allNotifications = {};
const uniqueUsers = [...new Set(recipients.map((r) => r.user))];
logger.logger.debug(`Unique users for jobId ${jobId}: ${uniqueUsers}`);
devDebugLogger(`Unique users for jobId ${jobId}: ${uniqueUsers}`);
for (const user of uniqueUsers) {
const userKey = `${redisKeyPrefix}:${user}`;
const notifications = await pubClient.get(userKey);
logger.logger.debug(`Retrieved notifications for ${user}: ${notifications}`);
devDebugLogger(`Retrieved notifications for ${user}: ${notifications}`);
if (notifications) {
const parsedNotifications = JSON.parse(notifications);
@@ -135,13 +142,13 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
allNotifications[user][bodyShopId] = parsedNotifications;
}
await pubClient.del(userKey);
logger.logger.debug(`Deleted Redis key ${userKey}`);
devDebugLogger(`Deleted Redis key ${userKey}`);
} else {
logger.logger.warn(`No notifications found for ${user} under ${userKey}`);
devDebugLogger(`No notifications found for ${user} under ${userKey}`);
}
}
logger.logger.debug(`Consolidated notifications: ${JSON.stringify(allNotifications)}`);
devDebugLogger(`Consolidated notifications: ${JSON.stringify(allNotifications)}`);
// Insert notifications into the database and collect IDs
const notificationInserts = [];
@@ -168,7 +175,7 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
const insertResponse = await graphQLClient.request(INSERT_NOTIFICATIONS_MUTATION, {
objects: notificationInserts
});
logger.logger.info(
devDebugLogger(
`Inserted ${insertResponse.insert_notifications.affected_rows} notifications for jobId ${jobId}`
);
@@ -202,51 +209,61 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
associationId
});
});
logger.logger.info(
devDebugLogger(
`Sent ${notifications.length} consolidated notifications to ${user} for jobId ${jobId} with notificationId ${notificationId}`
);
} else {
logger.logger.warn(`No socket IDs found for ${user} in bodyShopId ${bodyShopId}`);
devDebugLogger(`No socket IDs found for ${user} in bodyShopId ${bodyShopId}`);
}
}
}
await pubClient.del(`app:consolidate:${jobId}`);
await pubClient.del(`app:${devKey}:consolidate:${jobId}`);
} catch (err) {
logger.logger.error(`Consolidation error for jobId ${jobId}: ${err.message}`, { error: err });
logger.log(`app-queue-consolidation-error`, "ERROR", "notifications", "api", {
message: err?.message,
stack: err?.stack
});
throw err;
} finally {
await pubClient.del(lockKey);
}
} else {
logger.logger.info(`Skipped consolidation for jobId ${jobId} - lock held by another worker`);
devDebugLogger(`Skipped consolidation for jobId ${jobId} - lock held by another worker`);
}
},
{
prefix,
connection: pubClient,
prefix: "{BULLMQ}",
concurrency: 1,
limiter: { max: 1, duration: RATE_LIMITER_DURATION }
}
);
addWorker.on("completed", (job) => logger.logger.info(`Add job ${job.id} completed`));
consolidateWorker.on("completed", (job) => logger.logger.info(`Consolidate job ${job.id} completed`));
addWorker.on("completed", (job) => devDebugLogger(`Add job ${job.id} completed`));
consolidateWorker.on("completed", (job) => devDebugLogger(`Consolidate job ${job.id} completed`));
addWorker.on("failed", (job, err) =>
logger.logger.error(`Add job ${job.id} failed: ${err.message}`, { error: err })
logger.log(`app-queue-notification-error`, "ERROR", "notifications", "api", {
message: err?.message,
stack: err?.stack
})
);
consolidateWorker.on("failed", (job, err) =>
logger.logger.error(`Consolidate job ${job.id} failed: ${err.message}`, { error: err })
logger.log(`app-queue-consolidation-failed:`, "ERROR", "notifications", "api", {
message: err?.message,
stack: err?.stack
})
);
// Register cleanup task instead of direct process listeners
const shutdown = async () => {
logger.logger.info("Closing app queue workers...");
devDebugLogger("Closing app queue workers...");
await Promise.all([addWorker.close(), consolidateWorker.close()]);
logger.logger.info("App queue workers closed");
devDebugLogger("App queue workers closed");
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
registerCleanupTask(shutdown);
}
return addQueue;
@@ -273,7 +290,7 @@ const dispatchAppsToQueue = async ({ appsToDispatch, logger }) => {
{ jobId, bodyShopId, key, variables, recipients, body, jobRoNumber },
{ jobId: `${jobId}:${Date.now()}` }
);
logger.logger.info(`Added notification to queue for jobId ${jobId} with ${recipients.length} recipients`);
devDebugLogger(`Added notification to queue for jobId ${jobId} with ${recipients.length} recipients`);
}
};

View File

@@ -2,11 +2,15 @@ const { Queue, Worker } = require("bullmq");
const { sendTaskEmail } = require("../../email/sendemail");
const generateEmailTemplate = require("../../email/generateTemplate");
const { InstanceEndpoints } = require("../../utils/instanceMgr");
const { registerCleanupTask } = require("../../utils/cleanupManager");
const getBullMQPrefix = require("../../utils/getBullMQPrefix");
const devDebugLogger = require("../../utils/devDebugLogger");
const moment = require("moment-timezone");
const EMAIL_CONSOLIDATION_DELAY_IN_MINS = (() => {
const envValue = process.env?.APP_CONSOLIDATION_DELAY_IN_MINS;
const envValue = process.env?.EMAIL_CONSOLIDATION_DELAY_IN_MINS;
const parsedValue = envValue ? parseInt(envValue, 10) : NaN;
return isNaN(parsedValue) ? 1 : Math.max(1, parsedValue); // Default to 1, ensure at least 1
return isNaN(parsedValue) ? 3 : Math.max(1, parsedValue); // Default to 3, ensure at least 1
})();
// Base time-related constant (in milliseconds) / DO NOT TOUCH
@@ -33,19 +37,22 @@ let emailConsolidateWorker;
*/
const loadEmailQueue = async ({ pubClient, logger }) => {
if (!emailAddQueue || !emailConsolidateQueue) {
logger.logger.info("Initializing Email Notification Queues");
const prefix = getBullMQPrefix();
const devKey = process.env?.NODE_ENV === "production" ? "prod" : "dev";
devDebugLogger(`Initializing Email Notification Queues with prefix: ${prefix}`);
// Queue for adding email notifications
emailAddQueue = new Queue("emailAdd", {
prefix,
connection: pubClient,
prefix: "{BULLMQ}",
defaultJobOptions: { removeOnComplete: true, removeOnFail: true }
});
// Queue for consolidating and sending emails
emailConsolidateQueue = new Queue("emailConsolidate", {
prefix,
connection: pubClient,
prefix: "{BULLMQ}",
defaultJobOptions: { removeOnComplete: true, removeOnFail: true }
});
@@ -53,45 +60,47 @@ const loadEmailQueue = async ({ pubClient, logger }) => {
emailAddWorker = new Worker(
"emailAdd",
async (job) => {
const { jobId, jobRoNumber, bodyShopName, body, recipients } = job.data;
logger.logger.info(`Adding email notifications for jobId ${jobId}`);
const { jobId, jobRoNumber, bodyShopName, bodyShopTimezone, body, recipients } = job.data;
devDebugLogger(`Adding email notifications for jobId ${jobId}`);
const redisKeyPrefix = `email:${devKey}:notifications:${jobId}`;
const redisKeyPrefix = `email:notifications:${jobId}`;
for (const recipient of recipients) {
const { user, firstName, lastName } = recipient;
const userKey = `${redisKeyPrefix}:${user}`;
await pubClient.rpush(userKey, body);
await pubClient.expire(userKey, NOTIFICATION_EXPIRATION / 1000); // Set expiration
const detailsKey = `email:recipientDetails:${jobId}:${user}`;
await pubClient.expire(userKey, NOTIFICATION_EXPIRATION / 1000);
const detailsKey = `email:${devKey}:recipientDetails:${jobId}:${user}`;
await pubClient.hsetnx(detailsKey, "firstName", firstName || "");
await pubClient.hsetnx(detailsKey, "lastName", lastName || "");
await pubClient.expire(detailsKey, NOTIFICATION_EXPIRATION / 1000); // Set expiration
await pubClient.sadd(`email:recipients:${jobId}`, user);
logger.logger.debug(`Stored message for ${user} under ${userKey}: ${body}`);
await pubClient.hsetnx(detailsKey, "bodyShopTimezone", bodyShopTimezone);
await pubClient.expire(detailsKey, NOTIFICATION_EXPIRATION / 1000);
await pubClient.sadd(`email:${devKey}:recipients:${jobId}`, user);
devDebugLogger(`Stored message for ${user} under ${userKey}: ${body}`);
}
const consolidateKey = `email:consolidate:${jobId}`;
const consolidateKey = `email:${devKey}:consolidate:${jobId}`;
const flagSet = await pubClient.setnx(consolidateKey, "pending");
if (flagSet) {
await emailConsolidateQueue.add(
"consolidate-emails",
{ jobId, jobRoNumber, bodyShopName },
{ jobId, jobRoNumber, bodyShopName, bodyShopTimezone },
{
jobId: `consolidate:${jobId}`,
delay: EMAIL_CONSOLIDATION_DELAY,
attempts: 3, // Retry up to 3 times
backoff: LOCK_EXPIRATION // Retry delay matches lock expiration (15s)
attempts: 3,
backoff: LOCK_EXPIRATION
}
);
logger.logger.info(`Scheduled email consolidation for jobId ${jobId}`);
await pubClient.expire(consolidateKey, CONSOLIDATION_KEY_EXPIRATION / 1000); // Convert to seconds
devDebugLogger(`Scheduled email consolidation for jobId ${jobId}`);
await pubClient.expire(consolidateKey, CONSOLIDATION_KEY_EXPIRATION / 1000);
} else {
logger.logger.debug(`Email consolidation already scheduled for jobId ${jobId}`);
devDebugLogger(`Email consolidation already scheduled for jobId ${jobId}`);
}
},
{
prefix,
connection: pubClient,
prefix: "{BULLMQ}",
concurrency: 5
}
);
@@ -101,29 +110,30 @@ const loadEmailQueue = async ({ pubClient, logger }) => {
"emailConsolidate",
async (job) => {
const { jobId, jobRoNumber, bodyShopName } = job.data;
logger.logger.info(`Consolidating emails for jobId ${jobId}`);
devDebugLogger(`Consolidating emails for jobId ${jobId}`);
const lockKey = `lock:emailConsolidate:${jobId}`;
const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", LOCK_EXPIRATION / 1000); // Convert to seconds
const lockKey = `lock:${devKey}:emailConsolidate:${jobId}`;
const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", LOCK_EXPIRATION / 1000);
if (lockAcquired) {
try {
const recipientsSet = `email:recipients:${jobId}`;
const recipientsSet = `email:${devKey}:recipients:${jobId}`;
const recipients = await pubClient.smembers(recipientsSet);
for (const recipient of recipients) {
const userKey = `email:notifications:${jobId}:${recipient}`;
const detailsKey = `email:recipientDetails:${jobId}:${recipient}`;
const userKey = `email:${devKey}:notifications:${jobId}:${recipient}`;
const detailsKey = `email:${devKey}:recipientDetails:${jobId}:${recipient}`;
const messages = await pubClient.lrange(userKey, 0, -1);
if (messages.length > 0) {
const details = await pubClient.hgetall(detailsKey);
const firstName = details.firstName || "User";
const multipleUpdateString = messages.length > 1 ? "Updates" : "Update";
const subject = `${multipleUpdateString} for job ${jobRoNumber} at ${bodyShopName}`;
// Use the template instead of inline HTML
const subject = `${multipleUpdateString} for job ${jobRoNumber || "N/A"} at ${bodyShopName}`;
const timezone = moment.tz.zone(details?.bodyShopTimezone) ? details.bodyShopTimezone : "UTC";
const emailBody = generateEmailTemplate({
header: `${multipleUpdateString} for Job ${jobRoNumber}`,
header: `${multipleUpdateString} for Job ${jobRoNumber || "N/A"}`,
subHeader: `Dear ${firstName},`,
dateLine: moment().tz(timezone).format("MM/DD/YYYY hh:mm a"),
body: `
<p>There have been updates to job ${jobRoNumber} at ${bodyShopName}:</p><br/>
<p>There have been updates to job ${jobRoNumber || "N/A"} at ${bodyShopName}:</p><br/>
<ul>
${messages.map((msg) => `<li>${msg}</li>`).join("")}
</ul><br/><br/>
@@ -136,7 +146,7 @@ const loadEmailQueue = async ({ pubClient, logger }) => {
type: "html",
html: emailBody
});
logger.logger.info(
devDebugLogger(
`Sent consolidated email to ${recipient} for jobId ${jobId} with ${messages.length} updates`
);
await pubClient.del(userKey);
@@ -144,43 +154,52 @@ const loadEmailQueue = async ({ pubClient, logger }) => {
}
}
await pubClient.del(recipientsSet);
await pubClient.del(`email:consolidate:${jobId}`);
await pubClient.del(`email:${devKey}:consolidate:${jobId}`);
} catch (err) {
logger.logger.error(`Email consolidation error for jobId ${jobId}: ${err.message}`, { error: err });
throw err; // Trigger retry if attempts remain
logger.log(`email-queue-consolidation-error`, "ERROR", "notifications", "api", {
message: err?.message,
stack: err?.stack
});
throw err;
} finally {
await pubClient.del(lockKey);
}
} else {
logger.logger.info(`Skipped email consolidation for jobId ${jobId} - lock held by another worker`);
devDebugLogger(`Skipped email consolidation for jobId ${jobId} - lock held by another worker`);
}
},
{
prefix,
connection: pubClient,
prefix: "{BULLMQ}",
concurrency: 1,
limiter: { max: 1, duration: RATE_LIMITER_DURATION }
}
);
// Event handlers for workers
emailAddWorker.on("completed", (job) => logger.logger.info(`Email add job ${job.id} completed`));
emailConsolidateWorker.on("completed", (job) => logger.logger.info(`Email consolidate job ${job.id} completed`));
emailAddWorker.on("completed", (job) => devDebugLogger(`Email add job ${job.id} completed`));
emailConsolidateWorker.on("completed", (job) => devDebugLogger(`Email consolidate job ${job.id} completed`));
emailAddWorker.on("failed", (job, err) =>
logger.logger.error(`Email add job ${job.id} failed: ${err.message}`, { error: err })
logger.log(`add-email-queue-failed`, "ERROR", "notifications", "api", {
message: err?.message,
stack: err?.stack
})
);
emailConsolidateWorker.on("failed", (job, err) =>
logger.logger.error(`Email consolidate job ${job.id} failed: ${err.message}`, { error: err })
logger.log(`email-consolidation-job-failed`, "ERROR", "notifications", "api", {
message: err?.message,
stack: err?.stack
})
);
// Graceful shutdown
// Register cleanup task instead of direct process listeners
const shutdown = async () => {
logger.logger.info("Closing email queue workers...");
devDebugLogger("Closing email queue workers...");
await Promise.all([emailAddWorker.close(), emailConsolidateWorker.close()]);
logger.logger.info("Email queue workers closed");
devDebugLogger("Email queue workers closed");
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
registerCleanupTask(shutdown);
}
return emailAddQueue;
@@ -211,22 +230,22 @@ const dispatchEmailsToQueue = async ({ emailsToDispatch, logger }) => {
const emailAddQueue = getQueue();
for (const email of emailsToDispatch) {
const { jobId, jobRoNumber, bodyShopName, body, recipients } = email;
const { jobId, jobRoNumber, bodyShopName, bodyShopTimezone, body, recipients } = email;
if (!jobId || !jobRoNumber || !bodyShopName || !body || !recipients.length) {
logger.logger.warn(
devDebugLogger(
`Skipping email dispatch for jobId ${jobId} due to missing data: ` +
`jobRoNumber=${jobRoNumber}, bodyShopName=${bodyShopName}, body=${body}, recipients=${recipients.length}`
`jobRoNumber=${jobRoNumber || "N/A"}, bodyShopName=${bodyShopName}, body=${body}, recipients=${recipients.length}`
);
continue;
}
await emailAddQueue.add(
"add-email-notification",
{ jobId, jobRoNumber, bodyShopName, body, recipients },
{ jobId, jobRoNumber, bodyShopName, bodyShopTimezone, body, recipients },
{ jobId: `${jobId}:${Date.now()}` }
);
logger.logger.info(`Added email notification to queue for jobId ${jobId} with ${recipients.length} recipients`);
devDebugLogger(`Added email notification to queue for jobId ${jobId} with ${recipients.length} recipients`);
}
};

View File

@@ -1,494 +1,403 @@
const { getJobAssignmentType } = require("./stringHelpers");
const { getJobAssignmentType, formatTaskPriority } = require("./stringHelpers");
const moment = require("moment-timezone");
const { startCase } = require("lodash");
const Dinero = require("dinero.js");
Dinero.globalRoundingMode = "HALF_EVEN";
/**
* 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.
* Creates a standard notification object with app, email, and FCM properties and populates recipients.
* @param {Object} data - Input data containing jobId, jobRoNumber, bodyShopId, bodyShopName, and scenarioWatchers
* @param {string} key - Notification key for the app
* @param {string} body - Notification body text
* @param {Object} [variables={}] - Variables for the app notification
* @returns {Object} Notification object with populated recipients
*/
const populateWatchers = (data, result) => {
const buildNotification = (data, key, body, variables = {}) => {
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key,
body,
variables,
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
bodyShopTimezone: data.bodyShopTimezone,
body,
recipients: []
},
fcm: { recipients: [] }
};
// Populate recipients from scenarioWatchers
data.scenarioWatchers.forEach((recipients) => {
const { user, app, fcm, email, firstName, lastName, employeeId, associationId } = recipients;
if (app === true) result.app.recipients.push({ user, bodyShopId: data.bodyShopId, employeeId, associationId });
if (app === true)
result.app.recipients.push({
user,
bodyShopId: data.bodyShopId,
employeeId,
associationId
});
if (fcm === true) result.fcm.recipients.push(user);
if (email === true) result.email.recipients.push({ user, firstName, lastName });
});
return result;
};
/**
* Creates a notification for when the alternate transport is changed.
* @param data
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const alternateTransportChangedBuilder = (data) => {
const oldTransport = data?.changedFields?.alt_transport?.old;
const newTransport = data?.changedFields?.alt_transport?.new;
let body;
if (oldTransport && newTransport)
body = `The alternate transportation has been changed from ${oldTransport} to ${newTransport}.`;
else if (!oldTransport && newTransport) body = `The alternate transportation has been set to ${newTransport}.`;
else if (oldTransport && !newTransport)
body = `The alternate transportation has been canceled (previously ${oldTransport}).`;
else body = `The alternate transportation has been updated.`;
return buildNotification(data, "notifications.job.alternateTransportChanged", body, {
alternateTransport: newTransport,
oldAlternateTransport: oldTransport
});
};
/**
* Builds notification data for changes to alternate transport.
* Creates a notification for when a bill is posted.
* @param data
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const alternateTransportChangedBuilder = (data) => {
const body = `The alternate transport status has been updated from ${data?.changedFields?.altTransport?.old}.`;
const result = {
app: {
jobId: data.jobId,
bodyShopId: data.bodyShopId,
jobRoNumber: data.jobRoNumber,
key: "notifications.job.alternateTransportChanged",
body, // Same as email body
variables: {
alternateTransport: data.changedFields.alt_transport?.new,
oldAlternateTransport: data.changedFields.alt_transport?.old
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
const billPostedBuilder = (data) => {
const facing = data?.data?.isinhouse ? "in-house" : "vendor";
const body = `An ${facing} ${data?.data?.is_credit_memo ? "credit memo" : "bill"} has been posted.`.trim();
populateWatchers(data, result);
return result;
return buildNotification(data, "notifications.job.billPosted", body, {
isInHouse: data?.data?.isinhouse,
isCreditMemo: data?.data?.is_credit_memo
});
};
/**
* Builds notification data for bill posted events.
*/
const billPostedHandler = (data) => {
const body = `A bill of $${data.data.clm_total} has been posted.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.billPosted",
body,
variables: {
clmTotal: data.data.clm_total
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for changes to critical parts status.
* Creates a notification for when the status of critical parts changes.
* @param data
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const criticalPartsStatusChangedBuilder = (data) => {
const body = `The critical parts status has changed to ${data.data.queued_for_parts ? "queued" : "not queued"}.`;
const result = {
app: {
jobId: data.jobId,
bodyShopId: data.bodyShopId,
jobRoNumber: data.jobRoNumber,
key: "notifications.job.criticalPartsStatusChanged",
body,
variables: {
queuedForParts: data.data.queued_for_parts,
oldQueuedForParts: data.changedFields.queued_for_parts?.old
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
const lineDesc = data?.data?.line_desc;
const status = data?.data?.status;
const body = status
? `The status on a critical part line (${lineDesc}) has been set to ${status}.`
: `The status on a critical part line (${lineDesc}) has been cleared.`;
populateWatchers(data, result);
return result;
return buildNotification(data, "notifications.job.criticalPartsStatusChanged", body, {
joblineId: data?.data?.id,
status: data?.data?.status,
line_desc: lineDesc
});
};
/**
* Builds notification data for completed intake or delivery checklists.
* Creates a notification for when the intake or delivery checklist is completed.
* @param data
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const intakeDeliveryChecklistCompletedBuilder = (data) => {
const checklistType = data.changedFields.intakechecklist ? "intake" : "delivery";
const checklistType = data?.changedFields?.intakechecklist ? "intake" : "delivery";
const body = `The ${checklistType.charAt(0).toUpperCase() + checklistType.slice(1)} checklist has been completed.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.checklistCompleted",
body,
variables: {
checklistType,
completed: true
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
return buildNotification(data, "notifications.job.checklistCompleted", body, {
checklistType,
completed: true
});
};
/**
* Builds notification data for job assignment events.
* Creates a notification for when a job is assigned to the user.
* @param data
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const jobAssignedToMeBuilder = (data) => {
const body = `You have been assigned to [${getJobAssignmentType(data.scenarioFields?.[0])}]`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.assigned",
body,
variables: {
type: data.scenarioFields?.[0]
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
const body = `You have been assigned to ${getJobAssignmentType(data.scenarioFields?.[0])}.`;
populateWatchers(data, result);
return result;
return buildNotification(data, "notifications.job.assigned", body, {
type: data.scenarioFields?.[0]
});
};
/**
* Builds notification data for jobs added to production.
* Creates a notification for when jobs are added to production.
* @param data
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const jobsAddedToProductionBuilder = (data) => {
const body = `Job has been added to production.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.addedToProduction",
body,
variables: {},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
const body = `Job is now in production.`;
return buildNotification(data, "notifications.job.addedToProduction", body);
};
/**
* Builds notification data for job status changes.
* Creates a notification for when the job status changes.
* @param data
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const jobStatusChangeBuilder = (data) => {
const body = `The status has changed from ${data.changedFields.status.old} to ${data.changedFields.status.new}`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.statusChanged",
body,
variables: {
status: data.changedFields.status.new,
oldStatus: data.changedFields.status.old
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
const oldStatus = data?.changedFields?.status?.old;
const newStatus = data?.changedFields?.status?.new;
let body;
populateWatchers(data, result);
return result;
if (oldStatus && newStatus) body = `The status has been changed from ${oldStatus} to ${newStatus}.`;
else if (!oldStatus && newStatus) body = `The status has been set to ${newStatus}.`;
else if (oldStatus && !newStatus) body = `The status has been cleared (previously ${oldStatus}).`;
else body = `The status has been updated.`;
return buildNotification(data, "notifications.job.statusChanged", body, {
status: newStatus,
oldStatus: oldStatus
});
};
/**
* Builds notification data for new media added or reassigned events.
* Creates a notification for when new media is added or reassigned.
* @param data
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const newMediaAddedReassignedBuilder = (data) => {
const body = `New media has been added.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.newMediaAdded",
body,
variables: {},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
const mediaType = data?.data?.type?.startsWith("image") ? "Image" : "Document";
const action = data?.data?._documentMoved
? "moved to another job"
: data.isNew
? "added"
: data.changedFields?.jobid && data.changedFields.jobid.old !== data.changedFields.jobid.new
? "moved to this job"
: "updated";
const body = `An ${mediaType} has been ${action}.`;
populateWatchers(data, result);
return result;
return buildNotification(data, "notifications.job.newMediaAdded", body, {
mediaType,
action,
movedToJob: data?.data?._movedToJob
});
};
/**
* Builds notification data for new notes added to a job.
* Creates a notification for when a new note is added.
* @param data
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const newNoteAddedBuilder = (data) => {
const body = `A new note has been added: "${data.data.text}"`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.newNoteAdded",
body,
variables: {
text: data.data.text
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
const body = [
"A",
data?.data?.critical && "critical",
data?.data?.private && "private",
data?.data?.type,
"note has been added by",
`${data.data.created_by}`
]
.filter(Boolean)
.join(" ");
populateWatchers(data, result);
return result;
return buildNotification(data, "notifications.job.newNoteAdded", body, {
createdBy: data?.data?.created_by,
critical: data?.data?.critical,
type: data?.data?.type,
private: data?.data?.private
});
};
/**
* Builds notification data for new time tickets posted.
* Creates a notification for when a new time ticket is posted.
* @param data
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const newTimeTicketPostedBuilder = (data) => {
const body = `A new time ticket has been posted.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.newTimeTicketPosted",
body,
variables: {},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
const type = data?.data?.cost_center;
const body = `A ${startCase(type.toLowerCase())} time ticket for ${data?.data?.date} has been posted.`.trim();
populateWatchers(data, result);
return result;
return buildNotification(data, "notifications.job.newTimeTicketPosted", body, {
type,
date: data?.data?.date
});
};
/**
* Builds notification data for parts marked as back-ordered.
* Creates a notification for when a part is marked as back-ordered.
* @param data
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const partMarkedBackOrderedBuilder = (data) => {
const body = `A part has been marked as back-ordered.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.partBackOrdered",
body,
variables: {
queuedForParts: data.changedFields.queued_for_parts?.new,
oldQueuedForParts: data.changedFields.queued_for_parts?.old
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
const body = `A part ${data?.data?.line_desc} has been marked as back-ordered.`;
populateWatchers(data, result);
return result;
return buildNotification(data, "notifications.job.partBackOrdered", body, {
line_desc: data?.data?.line_desc
});
};
/**
* Builds notification data for payment collection events.
* Creates a notification for when payment is collected or completed.
* @param data
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const paymentCollectedCompletedBuilder = (data) => {
const body = `Payment of $${data.data.clm_total} has been collected.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.paymentCollected",
body,
variables: {
clmTotal: data.data.clm_total
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
const momentFormat = "MM/DD/YYYY";
const amountDinero = Dinero({ amount: Math.round((data.data.amount || 0) * 100) });
const amountFormatted = amountDinero.toFormat();
const payer = data.data.payer;
const paymentType = data.data.type;
const paymentDate = moment(data.data.date).format(momentFormat);
const body = `Payment of ${amountFormatted} has been collected from ${payer} via ${paymentType} on ${paymentDate}`;
populateWatchers(data, result);
return result;
return buildNotification(data, "notifications.job.paymentCollected", body, {
amount: data.data.amount,
payer: data.data.payer,
type: data.data.type,
date: data.data.date
});
};
/**
* Builds notification data for changes to scheduled dates.
* Creates a notification for when scheduled dates are changed.
* @param data
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const scheduledDatesChangedBuilder = (data) => {
const body = `Scheduled dates have been updated.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.scheduledDatesChanged",
body,
variables: {
scheduledIn: data.changedFields.scheduled_in?.new,
oldScheduledIn: data.changedFields.scheduled_in?.old,
scheduledCompletion: data.changedFields.scheduled_completion?.new,
oldScheduledCompletion: data.changedFields.scheduled_completion?.old,
scheduledDelivery: data.changedFields.scheduled_delivery?.new,
oldScheduledDelivery: data.changedFields.scheduled_delivery?.old
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
const changedFields = data.changedFields;
const fieldConfigs = {
scheduled_in: "Scheduled In",
scheduled_completion: "Scheduled Completion",
scheduled_delivery: "Scheduled Delivery"
};
const formatDateTime = (date) => {
if (!date) return "(no date set)";
const formatted = moment(date).tz(data.bodyShopTimezone);
return `${formatted.format("MM/DD/YYYY")} at ${formatted.format("hh:mm a")}`;
};
populateWatchers(data, result);
return result;
const fieldMessages = Object.entries(fieldConfigs)
.filter(([field]) => changedFields[field])
.map(([field, label]) => {
const { old, new: newValue } = changedFields[field];
if (old && !newValue) return `${label} was cancelled (previously ${formatDateTime(old)}).`;
else if (!old && newValue) return `${label} was set to ${formatDateTime(newValue)}.`;
else if (old && newValue) return `${label} changed from ${formatDateTime(old)} to ${formatDateTime(newValue)}.`;
return "";
})
.filter(Boolean);
const body = fieldMessages.length > 0 ? fieldMessages.join(" ") : "Scheduled dates have been updated.";
return buildNotification(data, "notifications.job.scheduledDatesChanged", body, {
scheduledIn: changedFields.scheduled_in?.new,
oldScheduledIn: changedFields.scheduled_in?.old,
scheduledCompletion: changedFields.scheduled_completion?.new,
oldScheduledCompletion: changedFields.scheduled_completion?.old,
scheduledDelivery: changedFields.scheduled_delivery?.new,
oldScheduledDelivery: changedFields.scheduled_delivery?.old
});
};
/**
* Builds notification data for supplement imported events.
*/
const supplementImportedBuilder = (data) => {
const body = `A supplement of $${data.data.cieca_ttl?.data?.supp_amt || 0} has been imported.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.supplementImported",
body,
variables: {
suppAmt: data.data.cieca_ttl?.data?.supp_amt
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for tasks updated or created.
* Creates a notification for when tasks are updated or created.
* @param data
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const tasksUpdatedCreatedBuilder = (data) => {
const body = `Tasks have been ${data.isNew ? "created" : "updated"}.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: data.isNew ? "notifications.job.taskCreated" : "notifications.job.taskUpdated",
body,
variables: {
isNew: data.isNew,
roNumber: data.jobRoNumber
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
const momentFormat = "MM/DD/YYYY hh:mm a";
const timezone = data.bodyShopTimezone;
const taskTitle = data?.data?.title ? `"${data.data.title}"` : "Unnamed Task";
populateWatchers(data, result);
return result;
let body, variables;
if (data.isNew) {
const priority = formatTaskPriority(data?.data?.priority);
const createdBy = data?.data?.created_by || "Unknown";
const dueDate = data.data.due_date ? ` due on ${moment(data.data.due_date).tz(timezone).format(momentFormat)}` : "";
const completedOnCreation = data.data.completed === true;
body = `A ${priority} task ${taskTitle} has been created${completedOnCreation ? " and marked completed" : ""} by ${createdBy}${dueDate}.`;
variables = {
isNew: data.isNew,
roNumber: data.jobRoNumber,
title: data?.data?.title,
priority: data?.data?.priority,
createdBy: data?.data?.created_by,
dueDate: data?.data?.due_date,
completed: completedOnCreation ? data?.data?.completed : undefined
};
} else {
const changedFields = data.changedFields;
const fieldNames = Object.keys(changedFields);
const oldTitle = changedFields.title ? `"${changedFields.title.old || "Unnamed Task"}"` : taskTitle;
if (fieldNames.length === 1 && changedFields.completed) {
body = `Task ${oldTitle} was marked ${changedFields.completed.new ? "complete" : "incomplete"}`;
variables = {
isNew: data.isNew,
roNumber: data.jobRoNumber,
title: data?.data?.title,
changedCompleted: changedFields.completed.new
};
} else {
const fieldMessages = [];
if (changedFields.title)
fieldMessages.push(`Task ${oldTitle} changed title to "${changedFields.title.new || "unnamed task"}".`);
if (changedFields.description) fieldMessages.push("Description updated.");
if (changedFields.priority)
fieldMessages.push(`Priority changed to ${formatTaskPriority(changedFields.priority.new)}.`);
if (changedFields.due_date)
fieldMessages.push(`Due date set to ${moment(changedFields.due_date.new).tz(timezone).format(momentFormat)}.`);
if (changedFields.completed)
fieldMessages.push(`Status changed to ${changedFields.completed.new ? "complete" : "incomplete"}.`);
body =
fieldMessages.length > 0
? fieldMessages.length === 1 && changedFields.title
? fieldMessages[0]
: `Task ${oldTitle} updated: ${fieldMessages.join(", ")}`
: `Task ${oldTitle} has been updated.`;
variables = {
isNew: data.isNew,
roNumber: data.jobRoNumber,
title: data?.data?.title,
changedTitleOld: changedFields.title?.old,
changedTitleNew: changedFields.title?.new,
changedPriority: changedFields.priority?.new,
changedDueDate: changedFields.due_date?.new,
changedCompleted: changedFields.completed?.new
};
}
}
return buildNotification(
data,
data.isNew ? "notifications.job.taskCreated" : "notifications.job.taskUpdated",
body,
variables
);
};
/**
* Creates a notification for when a supplement is imported.
* @param data
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const supplementImportedBuilder = (data) => {
const body = `A supplement has been imported.`;
return buildNotification(data, "notifications.job.supplementImported", body);
};
module.exports = {
alternateTransportChangedBuilder,
billPostedHandler,
billPostedBuilder,
criticalPartsStatusChangedBuilder,
intakeDeliveryChecklistCompletedBuilder,
jobAssignedToMeBuilder,

View File

@@ -0,0 +1,257 @@
const {
jobAssignedToMeBuilder,
billPostedBuilder,
newNoteAddedBuilder,
scheduledDatesChangedBuilder,
tasksUpdatedCreatedBuilder,
jobStatusChangeBuilder,
jobsAddedToProductionBuilder,
alternateTransportChangedBuilder,
newTimeTicketPostedBuilder,
intakeDeliveryChecklistCompletedBuilder,
paymentCollectedCompletedBuilder,
newMediaAddedReassignedBuilder,
criticalPartsStatusChangedBuilder,
supplementImportedBuilder,
partMarkedBackOrderedBuilder
} = require("./scenarioBuilders");
const logger = require("../utils/logger");
const { isFunction } = require("lodash");
/**
* An array of notification scenario definitions.
*
* Each scenario object can include the following properties:
* - key {string}: The unique scenario name.
* - table {string}: The table name to check for changes.
* - fields {Array<string>}: Fields to check for changes.
* - matchToUserFields {Array<string>}: Fields used to match scenarios to user data.
* - onNew {boolean|Array<boolean>}: Indicates whether the scenario should be triggered on new data.
* - builder {Function}: A function to handle the scenario.
* - onlyTruthyValues {boolean|Array<string>}: Specifies fields that must have truthy values for the scenario to match.
* - filterCallback {Function}: Optional callback (sync or async) to further filter the scenario based on event data (returns boolean).
* - enabled {boolean}: If true, the scenario is active; if false or omitted, the scenario is skipped.
*/
const notificationScenarios = [
{
key: "job-assigned-to-me",
enabled: true,
table: "jobs",
fields: ["employee_prep", "employee_body", "employee_csr", "employee_refinish"],
matchToUserFields: ["employee_prep", "employee_body", "employee_csr", "employee_refinish"],
builder: jobAssignedToMeBuilder
},
{
key: "bill-posted",
enabled: true,
table: "bills",
builder: billPostedBuilder,
onNew: true
},
{
key: "new-note-added",
enabled: true,
table: "notes",
builder: newNoteAddedBuilder,
onNew: true
},
{
key: "schedule-dates-changed",
enabled: true,
table: "jobs",
fields: ["scheduled_in", "scheduled_completion", "scheduled_delivery"],
builder: scheduledDatesChangedBuilder
},
{
key: "tasks-updated-created",
enabled: true,
table: "tasks",
fields: ["updated_at"],
// onNew: true,
builder: tasksUpdatedCreatedBuilder
},
{
key: "job-status-change",
enabled: true,
table: "jobs",
fields: ["status"],
builder: jobStatusChangeBuilder
},
{
key: "job-added-to-production",
enabled: true,
table: "jobs",
fields: ["inproduction"],
onlyTruthyValues: ["inproduction"],
builder: jobsAddedToProductionBuilder
},
{
key: "alternate-transport-changed",
enabled: true,
table: "jobs",
fields: ["alt_transport"],
builder: alternateTransportChangedBuilder
},
{
key: "new-time-ticket-posted",
enabled: true,
table: "timetickets",
builder: newTimeTicketPostedBuilder
},
{
key: "intake-delivery-checklist-completed",
enabled: true,
table: "jobs",
fields: ["intakechecklist", "deliverchecklist"],
builder: intakeDeliveryChecklistCompletedBuilder
},
{
key: "payment-collected-completed",
enabled: true,
table: "payments",
onNew: true,
builder: paymentCollectedCompletedBuilder
},
{
// Only works on a non LMS ENV
key: "new-media-added-reassigned",
enabled: true,
table: "documents",
fields: ["jobid"],
builder: newMediaAddedReassignedBuilder
},
{
key: "critical-parts-status-changed",
enabled: true,
table: "joblines",
fields: ["status"],
onlyTruthyValues: ["status"],
builder: criticalPartsStatusChangedBuilder,
filterCallback: ({ eventData }) => !eventData?.data?.critical
},
{
key: "part-marked-back-ordered",
enabled: true,
table: "joblines",
fields: ["status"],
builder: partMarkedBackOrderedBuilder,
filterCallback: async ({ eventData, getBodyshopFromRedis }) => {
try {
const bodyshop = await getBodyshopFromRedis(eventData.bodyShopId);
return eventData?.data?.status !== bodyshop?.md_order_statuses?.default_bo;
} catch (err) {
logger.log("notifications-error-parts-marked-back-ordered", "error", "notifications", "mapper", {
message: err?.message,
stack: err?.stack
});
return false;
}
}
},
// Holding off on this one for now, spans multiple tables
{
key: "supplement-imported",
enabled: false,
builder: supplementImportedBuilder
}
];
/**
* Returns an array of scenarios that match the given event data.
* Supports asynchronous callbacks for additional filtering.
*
* @param {Object} eventData - The parsed event data.
* Expected properties:
* - table: an object with a `name` property (e.g. { name: "tasks", schema: "public" })
* - changedFieldNames: an array of changed field names (e.g. [ "description", "updated_at" ])
* - isNew: boolean indicating whether the record is new or updated
* - data: the new data object (used to check field values)
* - (other properties may be added such as jobWatchers, bodyShopId, etc.)
* @param {Function} getBodyshopFromRedis - Function to retrieve bodyshop data from Redis.
* @returns {Promise<Array<Object>>} A promise resolving to an array of matching scenario objects.
*/
const getMatchingScenarios = async (eventData, getBodyshopFromRedis) => {
const matches = [];
for (const scenario of notificationScenarios) {
// Check if the scenario is enabled; skip if not explicitly true
if (scenario.enabled !== true) {
continue;
}
// If eventData has a table, then only scenarios with a table property that matches should be considered.
if (eventData.table) {
if (!scenario.table || eventData.table.name !== scenario.table) {
continue;
}
}
// Check the onNew flag.
// Allow onNew to be either a boolean or an array of booleans.
if (Object.prototype.hasOwnProperty.call(scenario, "onNew")) {
if (Array.isArray(scenario.onNew)) {
if (!scenario.onNew.includes(eventData.isNew)) continue;
} else {
if (eventData.isNew !== scenario.onNew) continue;
}
}
// If the scenario defines fields, ensure at least one of them is present in changedFieldNames.
if (scenario.fields && scenario.fields.length > 0) {
const hasMatchingField = scenario.fields.some((field) => eventData.changedFieldNames.includes(field));
if (!hasMatchingField) {
continue;
}
}
// OnlyTruthyValues logic:
// If onlyTruthyValues is defined, check that the new values of specified fields (or all changed fields if true)
// are truthy. If an array, only check the listed fields, which must be in scenario.fields.
if (Object.prototype.hasOwnProperty.call(scenario, "onlyTruthyValues")) {
let fieldsToCheck;
if (scenario.onlyTruthyValues === true) {
// If true, check all fields in the scenario that changed
fieldsToCheck = scenario.fields.filter((field) => eventData.changedFieldNames.includes(field));
} else if (Array.isArray(scenario.onlyTruthyValues) && scenario.onlyTruthyValues.length > 0) {
// If an array, check only the specified fields, ensuring they are in scenario.fields
fieldsToCheck = scenario.onlyTruthyValues.filter(
(field) => scenario.fields.includes(field) && eventData.changedFieldNames.includes(field)
);
// If no fields in onlyTruthyValues match the scenarios fields or changed fields, skip this scenario
if (fieldsToCheck.length === 0) {
continue;
}
} else {
// Invalid onlyTruthyValues (not true or a non-empty array), skip this scenario
continue;
}
// Ensure all fields to check have truthy new values
const allTruthy = fieldsToCheck.every((field) => Boolean(eventData.data[field]));
if (!allTruthy) {
continue;
}
}
// Execute the callback if defined, supporting both sync and async, and filter based on its return value
if (isFunction(scenario?.filterCallback)) {
const shouldFilter = await Promise.resolve(
scenario.filterCallback({
eventData,
getBodyshopFromRedis
})
);
if (shouldFilter) {
continue;
}
}
matches.push(scenario);
}
return matches;
};
module.exports = {
notificationScenarios,
getMatchingScenarios
};

View File

@@ -1,192 +0,0 @@
const {
jobAssignedToMeBuilder,
billPostedHandler,
newNoteAddedBuilder,
scheduledDatesChangedBuilder,
tasksUpdatedCreatedBuilder,
jobStatusChangeBuilder,
jobsAddedToProductionBuilder,
alternateTransportChangedBuilder,
newTimeTicketPostedBuilder,
intakeDeliveryChecklistCompletedBuilder,
paymentCollectedCompletedBuilder,
newMediaAddedReassignedBuilder,
criticalPartsStatusChangedBuilder,
supplementImportedBuilder,
partMarkedBackOrderedBuilder
} = require("./scenarioBuilders");
/**
* An array of notification scenario definitions.
*
* Each scenario object can include the following properties:
* - key {string}: The unique scenario name.
* - table {string}: The table name to check for changes.
* - fields {Array<string>}: Fields to check for changes.
* - matchToUserFields {Array<string>}: Fields used to match scenarios to user data.
* - onNew {boolean|Array<boolean>}: Indicates whether the scenario should be triggered on new data.
* - onlyTrue {Array<string>}: Specifies fields that must be true for the scenario to match.
* - builder {Function}: A function to handle the scenario.
*/
const notificationScenarios = [
{
key: "job-assigned-to-me",
table: "jobs",
fields: ["employee_pre", "employee_body", "employee_csr", "employee_refinish"],
matchToUserFields: ["employee_pre", "employee_body", "employee_csr", "employee_refinish"],
builder: jobAssignedToMeBuilder
},
{
key: "bill-posted",
table: "bills",
builder: billPostedHandler,
onNew: true
},
{
key: "new-note-added",
table: "notes",
builder: newNoteAddedBuilder,
onNew: true
},
{
key: "schedule-dates-changed",
table: "jobs",
fields: ["scheduled_in", "scheduled_completion", "scheduled_delivery"],
builder: scheduledDatesChangedBuilder
},
{
key: "tasks-updated-created",
table: "tasks",
fields: ["updated_at"],
// onNew: true,
builder: tasksUpdatedCreatedBuilder
},
{
key: "job-status-change",
table: "jobs",
fields: ["status"],
builder: jobStatusChangeBuilder
},
{
key: "job-added-to-production",
table: "jobs",
fields: ["inproduction"],
builder: jobsAddedToProductionBuilder
},
{
key: "alternate-transport-changed",
table: "jobs",
fields: ["alt_transport"],
builder: alternateTransportChangedBuilder
},
{
key: "new-time-ticket-posted",
table: "timetickets",
builder: newTimeTicketPostedBuilder
},
{
// Good test for batching as this will hit multiple scenarios
key: "intake-delivery-checklist-completed",
table: "jobs",
fields: ["intakechecklist"],
builder: intakeDeliveryChecklistCompletedBuilder
},
{
key: "payment-collected-completed",
table: "payments",
onNew: true,
builder: paymentCollectedCompletedBuilder
},
{
// MAKE SURE YOU ARE NOT ON A LMS ENVIRONMENT
// Potential Callbacks / Save for last
// Not question mark for Non LMS Scenario
key: "new-media-added-reassigned",
table: "documents",
fields: ["jobid"],
builder: newMediaAddedReassignedBuilder
},
{
key: "critical-parts-status-changed",
table: "joblines",
fields: ["critical"],
onlyTrue: ["critical"],
builder: criticalPartsStatusChangedBuilder
},
// -------------- Difficult ---------------
// Holding off on this one for now
{
key: "supplement-imported",
builder: supplementImportedBuilder
// spans multiple tables,
},
// This one may be tricky as the jobid is not directly in the event data (this is probably wrong)
// (should otherwise)
// Status needs to mark meta data 'md_backorderd' for example
// Double check Jobid
{
key: "part-marked-back-ordered",
table: "joblines",
builder: partMarkedBackOrderedBuilder
}
];
/**
* Returns an array of scenarios that match the given event data.
*
* @param {Object} eventData - The parsed event data.
* Expected properties:
* - table: an object with a `name` property (e.g. { name: "tasks", schema: "public" })
* - changedFieldNames: an array of changed field names (e.g. [ "description", "updated_at" ])
* - isNew: boolean indicating whether the record is new or updated
* - data: the new data object (used to check field values)
* - (other properties may be added such as jobWatchers, bodyShopId, etc.)
*
* @returns {Array<Object>} An array of matching scenario objects.
*/
const getMatchingScenarios = (eventData) =>
notificationScenarios.filter((scenario) => {
// If eventData has a table, then only scenarios with a table property that matches should be considered.
if (eventData.table) {
if (!scenario.table || eventData.table.name !== scenario.table) {
return false;
}
}
// Check the onNew flag.
// Allow onNew to be either a boolean or an array of booleans.
if (Object.prototype.hasOwnProperty.call(scenario, "onNew")) {
if (Array.isArray(scenario.onNew)) {
if (!scenario.onNew.includes(eventData.isNew)) return false;
} else {
if (eventData.isNew !== scenario.onNew) return false;
}
}
// If the scenario defines fields, ensure at least one of them is present in changedFieldNames.
if (scenario.fields && scenario.fields.length > 0) {
const hasMatchingField = scenario.fields.some((field) => eventData.changedFieldNames.includes(field));
if (!hasMatchingField) {
return false;
}
}
// OnlyTrue logic:
// If a scenario defines an onlyTrue array, then at least one of those fields must have changed
// and its new value (from eventData.data) must be non-falsey.
if (scenario.onlyTrue && Array.isArray(scenario.onlyTrue) && scenario.onlyTrue.length > 0) {
const hasTruthyChange = scenario.onlyTrue.some(
(field) => eventData.changedFieldNames.includes(field) && Boolean(eventData.data[field])
);
if (!hasTruthyChange) {
return false;
}
}
return true;
});
module.exports = {
notificationScenarios,
getMatchingScenarios
};

View File

@@ -11,31 +11,37 @@ const eventParser = require("./eventParser");
const { client: gqlClient } = require("../graphql-client/graphql-client");
const queries = require("../graphql-client/queries");
const { isEmpty, isFunction } = require("lodash");
const { getMatchingScenarios } = require("./scenarioMapperr");
const { getMatchingScenarios } = require("./scenarioMapper");
const { dispatchEmailsToQueue } = require("./queues/emailQueue");
const { dispatchAppsToQueue } = require("./queues/appQueue");
// If true, the user who commits the action will NOT receive notifications; if false, they will.
const FILTER_SELF_FROM_WATCHERS = (() => process.env.NODE_ENV === "production")();
const FILTER_SELF_FROM_WATCHERS = process.env?.FILTER_SELF_FROM_WATCHERS !== "false";
/**
* 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.
* @param {string} jobIdField - The field path (e.g., "req.body.event.new.id") to extract the job ID.
* @returns {Promise<void>} 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;
const {
logger,
sessionUtils: { getBodyshopFromRedis }
} = req;
// Validate we know what user committed the action that fired the parser
// Step 1: Validate we know what user committed the action that fired the parser
const hasuraUserRole = event?.session_variables?.["x-hasura-role"];
const hasuraUserId = event?.session_variables?.["x-hasura-user-id"];
// Bail if we don't know
if (!hasuraUserId) {
// Bail if we don't know who started the scenario
if (hasuraUserRole === "user" && !hasuraUserId) {
logger.log("No Hasura user ID found, skipping notification parsing", "info", "notifications");
return;
}
@@ -44,22 +50,31 @@ const scenarioParser = async (req, jobIdField) => {
throw new Error("Missing required request fields: event data, trigger, or table.");
}
// 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,
trigger,
table,
jobIdField
});
// Step 2: Extract just the jobId using the provided jobIdField
let jobId = null;
if (jobIdField) {
let keyName = jobIdField;
const prefix = "req.body.event.new.";
if (keyName.startsWith(prefix)) {
keyName = keyName.slice(prefix.length);
}
jobId = event.data.new[keyName] || (event.data.old && event.data.old[keyName]) || null;
}
if (!jobId) {
logger.log(`No jobId found using path "${jobIdField}", skipping notification parsing`, "info", "notifications");
return;
}
// Step 3: Query job watchers associated with the job ID using GraphQL
// Step 2: Query job watchers associated with the job ID using GraphQL
const watcherData = await gqlClient.request(queries.GET_JOB_WATCHERS, {
jobid: eventData.jobId
jobid: jobId
});
// Transform watcher data into a simplified format with email and employee details
let jobWatchers = watcherData?.job_watchers_aggregate?.nodes?.map((watcher) => ({
let jobWatchers = watcherData?.job_watchers?.map((watcher) => ({
email: watcher.user_email,
firstName: watcher?.user?.employee?.first_name,
lastName: watcher?.user?.employee?.last_name,
@@ -67,18 +82,32 @@ const scenarioParser = async (req, jobIdField) => {
authId: watcher?.user?.authid
}));
if (FILTER_SELF_FROM_WATCHERS) {
if (FILTER_SELF_FROM_WATCHERS && hasuraUserRole === "user") {
jobWatchers = jobWatchers.filter((watcher) => watcher.authId !== hasuraUserId);
}
// 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");
return;
}
// Step 3: Extract body shop information from the job data
// Step 5: Perform the full event diff now that we know there are watchers
const eventData = await eventParser({
newData: event.data.new,
oldData: event.data.old,
trigger,
table,
jobId
});
// Step 6: Extract body shop information from the job data
const bodyShopId = watcherData?.job?.bodyshop?.id;
const bodyShopName = watcherData?.job?.bodyshop?.shopname;
const bodyShopTimezone = watcherData?.job?.bodyshop?.timezone;
const jobRoNumber = watcherData?.job?.ro_number;
const jobClaimNumber = watcherData?.job?.clm_no;
@@ -87,16 +116,25 @@ const scenarioParser = async (req, jobIdField) => {
throw new Error("No bodyshop data found for this job.");
}
// Step 4: Identify scenarios that match the event data and job context
const matchingScenarios = getMatchingScenarios({
...eventData,
jobWatchers,
bodyShopId,
bodyShopName
});
// Step 7: Identify scenarios that match the event data and job context
const matchingScenarios = await getMatchingScenarios(
{
...eventData,
jobWatchers,
bodyShopId,
bodyShopName
},
getBodyshopFromRedis
);
// 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"
);
return;
}
@@ -106,10 +144,12 @@ const scenarioParser = async (req, jobIdField) => {
jobWatchers,
bodyShopId,
bodyShopName,
bodyShopTimezone,
matchingScenarios
};
// Step 5: Query notification settings for the job watchers
// Step 8: Query notification settings for the job watchers
const associationsData = await gqlClient.request(queries.GET_NOTIFICATION_ASSOCIATIONS, {
emails: jobWatchers.map((x) => x.email),
shopid: bodyShopId
@@ -117,10 +157,16 @@ 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"
);
return;
}
// Step 6: Filter scenario watchers based on their enabled notification methods
// Step 9: Filter scenario watchers based on their enabled notification methods
finalScenarioData.matchingScenarios = finalScenarioData.matchingScenarios.map((scenario) => ({
...scenario,
scenarioWatchers: associationsData.associations
@@ -150,10 +196,16 @@ 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"
);
return;
}
// Step 7: Build and collect scenarios to dispatch notifications for
// Step 10: Build and collect scenarios to dispatch notifications for
const scenariosToDispatch = [];
for (const scenario of finalScenarioData.matchingScenarios) {
@@ -178,7 +230,8 @@ const scenarioParser = async (req, jobIdField) => {
continue;
}
// Step 8: Filter scenario fields to include only those that changed
// Step 11: Filter scenario fields to include only those that changed
const filteredScenarioFields =
scenario.fields?.filter((field) => eventData.changedFieldNames.includes(field)) || [];
@@ -188,6 +241,7 @@ const scenarioParser = async (req, jobIdField) => {
trigger: finalScenarioData.trigger.name,
bodyShopId: finalScenarioData.bodyShopId,
bodyShopName: finalScenarioData.bodyShopName,
bodyShopTimezone: finalScenarioData.bodyShopTimezone,
scenarioKey: scenario.key,
scenarioTable: scenario.table,
scenarioFields: filteredScenarioFields,
@@ -204,35 +258,31 @@ const scenarioParser = async (req, jobIdField) => {
);
}
// Exit early if no scenarios are ready to dispatch
if (isEmpty(scenariosToDispatch)) {
logger.log(`No scenarios to dispatch for jobId "${jobId}" after building`, "info", "notifications");
return;
}
// Step 9: Dispatch email notifications to the email queue
// Step 12: 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
dispatchEmailsToQueue({ emailsToDispatch, logger }).catch((e) =>
logger.log("Something went wrong dispatching emails to the Email Notification Queue", "error", "queue", null, {
message: e?.message
message: e?.message,
stack: e?.stack
})
);
}
// Step 10: Dispatch app notifications to the app queue
// Step 13: 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
dispatchAppsToQueue({ appsToDispatch, logger }).catch((e) =>
logger.log("Something went wrong dispatching apps to the App Notification Queue", "error", "queue", null, {
message: e?.message
message: e?.message,
stack: e?.stack
})
);
}

View File

@@ -13,7 +13,7 @@
*/
const getJobAssignmentType = (data) => {
switch (data) {
case "employee_pre":
case "employee_prep":
return "Prep";
case "employee_body":
return "Body";
@@ -26,6 +26,17 @@ const getJobAssignmentType = (data) => {
}
};
module.exports = {
getJobAssignmentType
const formatTaskPriority = (priority) => {
if (priority === 1) {
return "High";
} else if (priority === 3) {
return "Low";
} else {
return "Medium";
}
};
module.exports = {
getJobAssignmentType,
formatTaskPriority
};

View File

@@ -13,6 +13,7 @@ const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLCl
const { taskAssignedEmail, tasksRemindEmail } = require("../email/tasksEmails");
const { canvastest } = require("../render/canvas-handler");
const { alertCheck } = require("../alerts/alertcheck");
const updateBodyshopCache = require("../web-sockets/updateBodyshopCache");
const uuid = require("uuid").v4;
//Test route to ensure Express is responding.
@@ -58,6 +59,7 @@ router.get("/test-logs", eventAuthorizationMiddleware, (req, res) => {
return res.status(500).send("Logs tested.");
});
router.get("/wstest", eventAuthorizationMiddleware, (req, res) => {
const { ioRedis } = req;
ioRedis.to(`bodyshop-broadcast-room:bfec8c8c-b7f1-49e0-be4c-524455f4e582`).emit("new-message-summary", {
@@ -137,4 +139,20 @@ router.post("/canvastest", validateFirebaseIdTokenMiddleware, canvastest);
// Alert Check
router.post("/alertcheck", eventAuthorizationMiddleware, alertCheck);
// Redis Cache Routes
router.post("/bodyshop-cache", eventAuthorizationMiddleware, updateBodyshopCache);
// Health Check for docker-compose-cluster load balancer, only available in development
if (process.env.NODE_ENV === "development") {
router.get("/health", (req, res) => {
const healthStatus = {
status: "healthy",
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || "unknown",
uptime: process.uptime()
};
res.status(200).json(healthStatus);
});
}
module.exports = router;

View File

@@ -0,0 +1,52 @@
// server/utils/cleanupManager.js
const logger = require("./logger");
let cleanupTasks = [];
let isShuttingDown = false;
/**
* Register a cleanup task to be executed during shutdown
* @param {Function} task - The cleanup task to register
*/
function registerCleanupTask(task) {
cleanupTasks.push(task);
}
/**
* Handle SIGTERM signal for graceful shutdown
*/
async function handleSigterm() {
if (isShuttingDown) {
logger.log("sigterm-api", "WARN", null, null, { message: "Shutdown already in progress, ignoring signal." });
return;
}
isShuttingDown = true;
logger.log("sigterm-api", "WARN", null, null, { message: "SIGTERM Received. Starting graceful shutdown." });
try {
for (const task of cleanupTasks) {
logger.log("sigterm-api", "WARN", null, null, { message: `Running cleanup task: ${task.name}` });
await task();
}
logger.log("sigterm-api", "WARN", null, null, { message: `All cleanup tasks completed.` });
} catch (error) {
logger.log("sigterm-api-error", "ERROR", null, null, { message: error.message, stack: error.stack });
}
process.exit(0);
}
/**
* Initialize cleanup manager with process event listeners
*/
function initializeCleanupManager() {
process.on("SIGTERM", handleSigterm);
process.on("SIGINT", handleSigterm); // Handle Ctrl+C
}
module.exports = {
registerCleanupTask,
initializeCleanupManager
};

View File

@@ -0,0 +1,10 @@
const logger = require("./logger");
const devDebugLogger = (message, meta) => {
if (process.env?.NODE_ENV === "production") {
return;
}
logger.logger.debug(message, meta);
};
module.exports = devDebugLogger;

View File

@@ -0,0 +1,3 @@
const getBullMQPrefix = () => (process.env?.NODE_ENV === "production" ? "{PROD-BULLMQ}" : "{DEV-BULLMQ}");
module.exports = getBullMQPrefix;

View File

@@ -1,7 +1,9 @@
const applyIOHelpers = ({ app, api, io, logger }) => {
const getBodyshopRoom = (bodyshopID) => `bodyshop-broadcast-room:${bodyshopID}`;
// Global Bodyshop Room
const getBodyshopRoom = (bodyshopId) => `bodyshop-broadcast-room:${bodyshopId}`;
// Messaging - conversation specific room to handle detailed messages when the user has a conversation open.
const getBodyshopConversationRoom = ({bodyshopId, conversationId}) =>
const getBodyshopConversationRoom = ({ bodyshopId, conversationId }) =>
`bodyshop-conversation-room:${bodyshopId}:${conversationId}`;
const ioHelpersAPI = {

View File

@@ -1,3 +1,48 @@
const { GET_BODYSHOP_BY_ID } = require("../graphql-client/queries");
const devDebugLogger = require("./devDebugLogger");
const client = require("../graphql-client/graphql-client").client;
const BODYSHOP_CACHE_TTL = 3600; // 1 hour
/**
* Generate a cache key for a bodyshop
* @param bodyshopId
* @returns {`bodyshop-cache:${string}`}
*/
const getBodyshopCacheKey = (bodyshopId) => `bodyshop-cache:${bodyshopId}`;
/**
* Generate a cache key for a user socket mapping
* @param email
* @returns {`user:${string}:${string}:socketMapping`}
*/
const getUserSocketMappingKey = (email) =>
`user:${process.env?.NODE_ENV === "production" ? "prod" : "dev"}:${email}:socketMapping`;
/**
* Fetch bodyshop data from the database
* @param bodyshopId
* @param logger
* @returns {Promise<*>}
*/
const fetchBodyshopFromDB = async (bodyshopId, logger) => {
try {
const response = await client.request(GET_BODYSHOP_BY_ID, { id: bodyshopId });
const bodyshop = response.bodyshops_by_pk;
if (!bodyshop) {
throw new Error(`Bodyshop with ID ${bodyshopId} not found`);
}
return bodyshop; // Return the full object as-is
} catch (error) {
logger.log("fetch-bodyshop-from-db", "ERROR", "redis", null, {
bodyshopId,
error: error?.message,
stack: error?.stack
});
throw error;
}
};
/**
* Apply Redis helper functions
* @param pubClient
@@ -33,112 +78,17 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
}
};
// Store multiple session data in Redis
const setMultipleSessionData = async (socketId, keyValues) => {
try {
// keyValues is expected to be an object { key1: value1, key2: value2, ... }
const entries = Object.entries(keyValues).map(([key, value]) => [key, JSON.stringify(value)]);
await pubClient.hset(`socket:${socketId}`, ...entries.flat());
} catch (error) {
logger.log(`Error Setting Multiple Session Data for socket ${socketId}: ${error}`, "ERROR", "redis");
}
};
// Retrieve multiple session data from Redis
const getMultipleSessionData = async (socketId, keys) => {
try {
const data = await pubClient.hmget(`socket:${socketId}`, keys);
// Redis returns an object with null values for missing keys, so we parse the non-null ones
return Object.fromEntries(keys.map((key, index) => [key, data[index] ? JSON.parse(data[index]) : null]));
} catch (error) {
logger.log(`Error Getting Multiple Session Data for socket ${socketId}: ${error}`, "ERROR", "redis");
}
};
const setMultipleFromArraySessionData = async (socketId, keyValueArray) => {
try {
// Use Redis multi/pipeline to batch the commands
const multi = pubClient.multi();
keyValueArray.forEach(([key, value]) => {
multi.hset(`socket:${socketId}`, key, JSON.stringify(value));
});
await multi.exec(); // Execute all queued commands
} catch (error) {
logger.log(`Error Setting Multiple Session Data for socket ${socketId}: ${error}`, "ERROR", "redis");
}
};
// Helper function to add an item to the end of the Redis list
const addItemToEndOfList = async (socketId, key, newItem) => {
try {
await pubClient.rpush(`socket:${socketId}:${key}`, JSON.stringify(newItem));
} catch (error) {
let userEmail = "unknown";
let socketMappings = {};
try {
const userData = await getSessionData(socketId, "user");
if (userData && userData.email) {
userEmail = userData.email;
socketMappings = await getUserSocketMapping(userEmail);
}
} catch (sessionError) {
logger.log(`Failed to fetch session data for socket ${socketId}: ${sessionError}`, "ERROR", "redis");
}
const mappingString = JSON.stringify(socketMappings, null, 2);
const errorMessage = `Error adding item to the end of the list for socket ${socketId}: ${error}. User: ${userEmail}, Socket Mappings: ${mappingString}`;
logger.log(errorMessage, "ERROR", "redis");
}
};
// Helper function to add an item to the beginning of the Redis list
const addItemToBeginningOfList = async (socketId, key, newItem) => {
try {
await pubClient.lpush(`socket:${socketId}:${key}`, JSON.stringify(newItem));
} catch (error) {
logger.log(`Error adding item to the beginning of the list for socket ${socketId}: ${error}`, "ERROR", "redis");
}
};
// Helper function to clear a list in Redis
const clearList = async (socketId, key) => {
try {
await pubClient.del(`socket:${socketId}:${key}`);
} catch (error) {
logger.log(`Error clearing list for socket ${socketId}: ${error}`, "ERROR", "redis");
}
};
// Add methods to manage room users
const addUserToRoom = async (room, user) => {
try {
await pubClient.sadd(room, JSON.stringify(user));
} catch (error) {
logger.log(`Error adding user to room ${room}: ${error}`, "ERROR", "redis");
}
};
const removeUserFromRoom = async (room, user) => {
try {
await pubClient.srem(room, JSON.stringify(user));
} catch (error) {
logger.log(`Error removing user to room ${room}: ${error}`, "ERROR", "redis");
}
};
const getUsersInRoom = async (room) => {
try {
const users = await pubClient.smembers(room);
return users.map((user) => JSON.parse(user));
} catch (error) {
logger.log(`Error getting users in room ${room}: ${error}`, "ERROR", "redis");
}
};
/**
* Add a socket mapping for a user
* @param email
* @param socketId
* @param bodyshopId
* @returns {Promise<void>}
*/
const addUserSocketMapping = async (email, socketId, bodyshopId) => {
const userKey = `user:${email}`;
const socketMappingKey = `${userKey}:socketMapping`;
const socketMappingKey = getUserSocketMappingKey(email);
try {
logger.log(`Adding socket ${socketId} to user ${email} for bodyshop ${bodyshopId}`, "debug", "redis");
devDebugLogger(`Adding socket ${socketId} to user ${email} for bodyshop ${bodyshopId}`);
// Save the mapping: socketId -> bodyshopId
await pubClient.hset(socketMappingKey, socketId, bodyshopId);
// Set TTL (24 hours) for the mapping hash
@@ -148,38 +98,45 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
}
};
/**
* Refresh the TTL for a user's socket mapping
* @param email
* @returns {Promise<void>}
*/
const refreshUserSocketTTL = async (email) => {
const userKey = `user:${email}`;
const socketMappingKey = `${userKey}:socketMapping`;
const socketMappingKey = getUserSocketMappingKey(email);
try {
const exists = await pubClient.exists(socketMappingKey);
if (exists) {
await pubClient.expire(socketMappingKey, 86400);
logger.log(`Refreshed TTL for ${email} socket mapping`, "debug", "redis");
devDebugLogger(`Refreshed TTL for ${email} socket mapping`);
}
} catch (error) {
logger.log(`Error refreshing TTL for ${email}: ${error}`, "ERROR", "redis");
}
};
/**
* Remove a socket mapping for a user
* @param email
* @param socketId
* @returns {Promise<void>}
*/
const removeUserSocketMapping = async (email, socketId) => {
const userKey = `user:${email}`;
const socketMappingKey = `${userKey}:socketMapping`;
const socketMappingKey = getUserSocketMappingKey(email);
try {
logger.log(`Removing socket ${socketId} mapping for user ${email}`, "DEBUG", "redis");
devDebugLogger(`Removing socket ${socketId} mapping for user ${email}`);
// Look up the bodyshopId associated with this socket
const bodyshopId = await pubClient.hget(socketMappingKey, socketId);
if (!bodyshopId) {
logger.log(`Socket ${socketId} not found for user ${email}`, "DEBUG", "redis");
devDebugLogger(`Socket ${socketId} not found for user ${email}`);
return;
}
// Remove the socket mapping
await pubClient.hdel(socketMappingKey, socketId);
logger.log(
`Removed socket ${socketId} (associated with bodyshop ${bodyshopId}) for user ${email}`,
"DEBUG",
"redis"
);
devDebugLogger(`Removed socket ${socketId} (associated with bodyshop ${bodyshopId}) for user ${email}`);
// Refresh TTL if any socket mappings remain
const remainingSockets = await pubClient.hlen(socketMappingKey);
@@ -191,9 +148,14 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
}
};
/**
* Get all socket mappings for a user
* @param email
* @returns {Promise<{}>}
*/
const getUserSocketMapping = async (email) => {
const userKey = `user:${email}`;
const socketMappingKey = `${userKey}:socketMapping`;
const socketMappingKey = getUserSocketMappingKey(email);
try {
// Retrieve all socket mappings for the user
const mapping = await pubClient.hgetall(socketMappingKey);
@@ -213,23 +175,235 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
}
};
/**
* Get socket IDs for a user by bodyshopId
* @param email
* @param bodyshopId
* @returns {Promise<{socketIds: [string, string], ttl: *}>}
*/
const getUserSocketMappingByBodyshop = async (email, bodyshopId) => {
const socketMappingKey = getUserSocketMappingKey(email);
try {
// Retrieve all socket mappings for the user
const mapping = await pubClient.hgetall(socketMappingKey);
const ttl = await pubClient.ttl(socketMappingKey);
// Filter socket IDs for the provided bodyshopId
const socketIds = Object.entries(mapping).reduce((acc, [socketId, bId]) => {
if (bId === bodyshopId) {
acc.push(socketId);
}
return acc;
}, []);
return { socketIds, ttl };
} catch (error) {
logger.log(`Error retrieving socket mappings for ${email} by bodyshop ${bodyshopId}: ${error}`, "ERROR", "redis");
throw error;
}
};
/**
* Get bodyshop data from Redis
* @param bodyshopId
* @returns {Promise<*>}
*/
const getBodyshopFromRedis = async (bodyshopId) => {
const key = getBodyshopCacheKey(bodyshopId);
try {
// Check if data exists in Redis
const cachedData = await pubClient.get(key);
if (cachedData) {
return JSON.parse(cachedData); // Parse and return the full object
}
// Cache miss: fetch from DB
const bodyshopData = await fetchBodyshopFromDB(bodyshopId, logger);
// Store in Redis as a single JSON string
const jsonData = JSON.stringify(bodyshopData);
await pubClient.set(key, jsonData);
await pubClient.expire(key, BODYSHOP_CACHE_TTL);
devDebugLogger("bodyshop-cache-miss", {
bodyshopId,
action: "Fetched from DB and cached"
});
return bodyshopData; // Return the full object
} catch (error) {
logger.log("get-bodyshop-from-redis", "ERROR", "redis", null, {
bodyshopId,
error: error.message
});
throw error;
}
};
/**
* Update or invalidate bodyshop data in Redis
* @param bodyshopId
* @param values
* @returns {Promise<void>}
*/
const updateOrInvalidateBodyshopFromRedis = async (bodyshopId, values = null) => {
const key = getBodyshopCacheKey(bodyshopId);
try {
if (!values) {
// Invalidate cache by deleting the key
await pubClient.del(key);
devDebugLogger("bodyshop-cache-invalidate", {
bodyshopId,
action: "Cache invalidated"
});
} else {
// Update cache with the full provided values
const jsonData = JSON.stringify(values);
await pubClient.set(key, jsonData);
await pubClient.expire(key, BODYSHOP_CACHE_TTL);
devDebugLogger("bodyshop-cache-update", {
bodyshopId,
action: "Cache updated",
values
});
}
} catch (error) {
logger.log("update-or-invalidate-bodyshop-from-redis", "ERROR", "api", "redis", {
bodyshopId,
values,
error: error.message
});
throw error;
}
};
// NOTE: The following code was written for an abandoned branch and things have changes since the,
// Leaving it here for demonstration purposes, commenting it out so it does not get used
// Store multiple session data in Redis
// const setMultipleSessionData = async (socketId, keyValues) => {
// try {
// // keyValues is expected to be an object { key1: value1, key2: value2, ... }
// const entries = Object.entries(keyValues).map(([key, value]) => [key, JSON.stringify(value)]);
// await pubClient.hset(`socket:${socketId}`, ...entries.flat());
// } catch (error) {
// logger.log(`Error Setting Multiple Session Data for socket ${socketId}: ${error}`, "ERROR", "redis");
// }
// };
// Retrieve multiple session data from Redis
// const getMultipleSessionData = async (socketId, keys) => {
// try {
// const data = await pubClient.hmget(`socket:${socketId}`, keys);
// // Redis returns an object with null values for missing keys, so we parse the non-null ones
// return Object.fromEntries(keys.map((key, index) => [key, data[index] ? JSON.parse(data[index]) : null]));
// } catch (error) {
// logger.log(`Error Getting Multiple Session Data for socket ${socketId}: ${error}`, "ERROR", "redis");
// }
// };
// const setMultipleFromArraySessionData = async (socketId, keyValueArray) => {
// try {
// // Use Redis multi/pipeline to batch the commands
// const multi = pubClient.multi();
// keyValueArray.forEach(([key, value]) => {
// multi.hset(`socket:${socketId}`, key, JSON.stringify(value));
// });
// await multi.exec(); // Execute all queued commands
// } catch (error) {
// logger.log(`Error Setting Multiple Session Data for socket ${socketId}: ${error}`, "ERROR", "redis");
// }
// };
// Helper function to add an item to the end of the Redis list
// const addItemToEndOfList = async (socketId, key, newItem) => {
// try {
// await pubClient.rpush(`socket:${socketId}:${key}`, JSON.stringify(newItem));
// } catch (error) {
// let userEmail = "unknown";
// let socketMappings = {};
// try {
// const userData = await getSessionData(socketId, "user");
// if (userData && userData.email) {
// userEmail = userData.email;
// socketMappings = await getUserSocketMapping(userEmail);
// }
// } catch (sessionError) {
// logger.log(`Failed to fetch session data for socket ${socketId}: ${sessionError}`, "ERROR", "redis");
// }
// const mappingString = JSON.stringify(socketMappings, null, 2);
// const errorMessage = `Error adding item to the end of the list for socket ${socketId}: ${error}. User: ${userEmail}, Socket Mappings: ${mappingString}`;
// logger.log(errorMessage, "ERROR", "redis");
// }
// };
// Helper function to add an item to the beginning of the Redis list
// const addItemToBeginningOfList = async (socketId, key, newItem) => {
// try {
// await pubClient.lpush(`socket:${socketId}:${key}`, JSON.stringify(newItem));
// } catch (error) {
// logger.log(`Error adding item to the beginning of the list for socket ${socketId}: ${error}`, "ERROR", "redis");
// }
// };
// Helper function to clear a list in Redis
// const clearList = async (socketId, key) => {
// try {
// await pubClient.del(`socket:${socketId}:${key}`);
// } catch (error) {
// logger.log(`Error clearing list for socket ${socketId}: ${error}`, "ERROR", "redis");
// }
// };
// Add methods to manage room users
// const addUserToRoom = async (room, user) => {
// try {
// await pubClient.sadd(room, JSON.stringify(user));
// } catch (error) {
// logger.log(`Error adding user to room ${room}: ${error}`, "ERROR", "redis");
// }
// };
// Remove users from room
// const removeUserFromRoom = async (room, user) => {
// try {
// await pubClient.srem(room, JSON.stringify(user));
// } catch (error) {
// logger.log(`Error removing user to room ${room}: ${error}`, "ERROR", "redis");
// }
// };
// Get Users in room
// const getUsersInRoom = async (room) => {
// try {
// const users = await pubClient.smembers(room);
// return users.map((user) => JSON.parse(user));
// } catch (error) {
// logger.log(`Error getting users in room ${room}: ${error}`, "ERROR", "redis");
// }
// };
const api = {
getUserSocketMappingKey,
getBodyshopCacheKey,
setSessionData,
getSessionData,
clearSessionData,
setMultipleSessionData,
getMultipleSessionData,
setMultipleFromArraySessionData,
addItemToEndOfList,
addItemToBeginningOfList,
clearList,
addUserToRoom,
removeUserFromRoom,
getUsersInRoom,
addUserSocketMapping,
removeUserSocketMapping,
getUserSocketMappingByBodyshop,
getUserSocketMapping,
refreshUserSocketTTL
refreshUserSocketTTL,
getBodyshopFromRedis,
updateOrInvalidateBodyshopFromRedis
// setMultipleSessionData,
// getMultipleSessionData,
// setMultipleFromArraySessionData,
// addItemToEndOfList,
// addItemToBeginningOfList,
// clearList,
// addUserToRoom,
// removeUserFromRoom,
// getUsersInRoom,
};
Object.assign(module.exports, api);

View File

@@ -2,7 +2,7 @@ const { admin } = require("../firebase/firebase-handler");
const redisSocketEvents = ({
io,
redisHelpers: { addUserSocketMapping, removeUserSocketMapping, refreshUserSocketTTL },
redisHelpers: { addUserSocketMapping, removeUserSocketMapping, refreshUserSocketTTL, getUserSocketMappingByBodyshop },
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom },
logger
}) => {
@@ -14,12 +14,15 @@ const redisSocketEvents = ({
// Socket Auth Middleware
const authMiddleware = async (socket, next) => {
const { token, bodyshopId } = socket.handshake.auth;
if (!token) {
return next(new Error("Authentication error - no authorization token."));
}
if (!bodyshopId) {
return next(new Error("Authentication error - no bodyshopId provided."));
}
try {
const user = await admin.auth().verifyIdToken(token);
socket.user = user;
@@ -182,11 +185,58 @@ const redisSocketEvents = ({
socket.on("leave-bodyshop-conversation", leaveConversationRoom);
};
// Sync Notification Read Events
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();
if (socketMapping?.socketIds) {
socketMapping?.socketIds.forEach((socketId) => {
if (socketId !== socket.id) {
// Avoid sending back to the originating socket
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}`);
}
});
socket.on("sync-all-notifications-read", async ({ email, bodyshopId }) => {
try {
const socketMapping = await getUserSocketMappingByBodyshop(email, bodyshopId);
const timestamp = new Date().toISOString();
if (socketMapping?.socketIds) {
socketMapping?.socketIds.forEach((socketId) => {
if (socketId !== socket.id) {
// Avoid sending back to the originating socket
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}`);
}
});
};
// Call Handlers
registerRoomAndBroadcastEvents(socket);
registerUpdateEvents(socket);
registerMessagingEvents(socket);
registerDisconnectEvents(socket);
registerSyncEvents(socket);
};
// Associate Middleware and Handlers

View File

@@ -0,0 +1,36 @@
/**
* Update or invalidate bodyshop cache
* @param req
* @param res
* @returns {Promise<void>}
*/
const updateBodyshopCache = async (req, res) => {
const {
sessionUtils: { updateOrInvalidateBodyshopFromRedis },
logger
} = req;
const { event } = req.body;
const { new: newData } = event.data;
try {
if (newData && newData.id) {
// Update cache with the full new data object
await updateOrInvalidateBodyshopFromRedis(newData.id, newData);
logger.logger.debug("Bodyshop cache updated successfully.");
} else {
// Invalidate cache if no valid data provided
await updateOrInvalidateBodyshopFromRedis(newData.id);
logger.logger.debug("Bodyshop cache invalidated successfully.");
}
res.status(200).json({ success: true });
} catch (error) {
logger.log("bodyshop-cache-update-error", "ERROR", "api", "redis", {
message: error?.message,
stack: error?.stack
});
res.status(500).json({ success: false, error: error.message });
}
};
module.exports = updateBodyshopCache;