Compare commits

..

37 Commits

Author SHA1 Message Date
Allan Carr
99cf95daf0 IO-3246 Remote Assist
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-23 16:45:24 -07:00
Allan Carr
8c1758ae49 Merged in feature/IO-3075-Crisp-Basic-Info (pull request #2335)
IO-3075 Crisp Basic Info

Approved-by: Dave Richer
2025-05-23 18:03:15 +00:00
Allan Carr
2d764921ff IO-3075 Crisp Basic Info
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-23 10:43:15 -07:00
Dave Richer
4859239f55 Merged in feature/IO-3242-Visual-Production-Board-Vertical-Drag-Bug (pull request #2332)
feature/IO-3242-Visual-Production-Board-Vertical-Drag-Bug - Fix bug
2025-05-23 15:01:44 +00:00
Dave Richer
5c64d7185e feature/IO-3242-Visual-Production-Board-Vertical-Drag-Bug - Fix bug 2025-05-23 11:00:21 -04:00
Patrick Fic
152479bc08 Merged in feature/IO-3239-integration-logging (pull request #2331)
Feature/IO-3239 integration logging
2025-05-22 18:56:05 +00:00
Patrick Fic
2c508cf1a1 IO-3239 QBO Logging and integration log schema changes. 2025-05-22 11:54:17 -07:00
Patrick Fic
16a91c772a Merged in release/2025-06-02 (pull request #2330)
Release/2025 06 02
2025-05-22 16:46:27 +00:00
Dave Richer
5c47088b11 release/2025-06-02 - Lint Updates 2025-05-22 11:37:47 -04:00
Allan Carr
8e5dc4fa71 Merged in feature/IO-3243-Job-Costing-TOW (pull request #2327)
IO-3243 Job Costing TOW

Approved-by: Dave Richer
2025-05-22 15:13:34 +00:00
Allan Carr
39c3729f6d Merged in feature/IO-3230-Customer-List-Excel (pull request #2326)
IO-3230 Customer List Excel

Approved-by: Dave Richer
2025-05-22 15:13:03 +00:00
Allan Carr
e3d854e02b IO-3243 Job Costing TOW
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-21 17:53:33 -07:00
Allan Carr
618acf2acf IO-3230 Customer List Excel
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-05-21 16:28:09 -07:00
Dave Richer
2cf2b70293 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2325)
Feature/IO-3182 Phone Number Consent
2025-05-21 19:17:52 +00:00
Dave Richer
0541afceb8 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 15:17:11 -04:00
Dave Richer
28ed3f9936 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2324)
DO NOT MERGE JUST USING TO UNDO
2025-05-21 19:03:58 +00:00
Dave Richer
6afa50332b feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 15:03:02 -04:00
Dave Richer
8c8c68867d feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 14:39:17 -04:00
Dave Richer
8ee52598e8 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 14:32:35 -04:00
Allan Carr
c822028174 Merged in feature/IO-3235-FeatureAccess-VisualBoard-Color (pull request #2322)
IO-3235 FeatureAccess on VisualBoard for SmartSchedule Option of Color Cards

Approved-by: Dave Richer
2025-05-21 18:06:04 +00:00
Allan Carr
36b82c6195 Merged in feature/IO-3236-HasFeatureAccess-Date (pull request #2323)
IO-3236 HasFeatureAccess Date

Approved-by: Dave Richer
2025-05-21 18:05:23 +00:00
Allan Carr
079dffce4d IO-3236 HasFeatureAccess Date
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-05-20 17:16:27 -07:00
Dave Richer
7bd5190bf2 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-20 18:19:39 -04:00
Dave Richer
83860152a9 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-20 16:04:36 -04:00
Dave Richer
1e10493615 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2321)
Feature/IO-3182 Phone Number Consent
2025-05-20 17:45:24 +00:00
Dave Richer
9d81c68a4d feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-20 13:14:05 -04:00
Dave Richer
985d066978 feature/IO-3182-Phone-Number-Consent - Finish Database changes 2025-05-20 12:49:32 -04:00
Dave Richer
6ad9e27d1d feature/IO-3182-Phone-Number-Consent - Merge master / bump deps 2025-05-20 12:38:31 -04:00
Dave Richer
19ebdda5b3 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3182-Phone-Number-Consent 2025-05-20 12:30:15 -04:00
Allan Carr
4602dd1183 Merged in master-AIO (pull request #2320)
IO-3217 OTSL Labor Type
2025-05-19 21:03:00 +00:00
Patrick Fic
6d59e3994f Merged in feature/IO-3105-qbo-version-update (pull request #2318)
feature/IO-3105-qbo-version-update

Approved-by: Patrick Fic
2025-05-19 19:30:34 +00:00
Patrick Fic
f770b2f1b1 IO-3105 Add QBO Minor Version. 2025-05-19 12:28:50 -07:00
Patrick Fic
b014744940 IO-3239 Add integration log statements on QBO. 2025-05-19 11:10:02 -07:00
Dave Richer
714c90c25e Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3182-Phone-Number-Consent 2025-05-15 18:55:23 -04:00
Dave Richer
9a3a971da6 Clear Stage 2025-05-15 18:55:20 -04:00
Dave Richer
96cba0aaab Clear Stage 2025-05-15 18:54:55 -04:00
Allan Carr
d4f718c44c Merged in feature/IO-3210-Podium-Datapump (pull request #2314)
IO-3210 Podium Datapump

Approved-by: Patrick Fic
2025-05-15 22:39:28 +00:00
59 changed files with 1652 additions and 1427 deletions

355
client/package-lock.json generated
View File

@@ -13,19 +13,19 @@
"@apollo/client": "^3.13.6",
"@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.6.1",
"@firebase/analytics": "^0.10.13",
"@firebase/app": "^0.12.1",
"@firebase/auth": "^1.10.2",
"@firebase/firestore": "^4.7.12",
"@firebase/messaging": "^0.12.18",
"@firebase/analytics": "^0.10.16",
"@firebase/app": "^0.13.0",
"@firebase/auth": "^1.10.5",
"@firebase/firestore": "^4.7.15",
"@firebase/messaging": "^0.12.21",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.8.1",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.45.0",
"@sentry/react": "^9.18.0",
"@sentry/vite-plugin": "^3.4.0",
"@sentry/react": "^9.22.0",
"@sentry/vite-plugin": "^3.5.0",
"@splitsoftware/splitio-react": "^2.1.1",
"@tanem/react-nprogress": "^5.0.53",
"antd": "^5.25.1",
"antd": "^5.25.2",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.3.0",
"autosize": "^6.0.1",
@@ -49,7 +49,7 @@
"normalize-url": "^8.0.1",
"object-hash": "^3.0.0",
"prop-types": "^15.8.1",
"query-string": "^9.1.2",
"query-string": "^9.2.0",
"raf-schd": "^4.0.3",
"react": "^18.3.1",
"react-big-calendar": "^1.18.0",
@@ -78,7 +78,7 @@
"redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"sass": "^1.88.0",
"sass": "^1.89.0",
"socket.io-client": "^4.8.1",
"styled-components": "^6.1.18",
"subscriptions-transport-ws": "^0.11.0",
@@ -90,12 +90,12 @@
"@ant-design/icons": "^6.0.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.27.1",
"@dotenvx/dotenvx": "^1.44.0",
"@dotenvx/dotenvx": "^1.44.1",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.26.0",
"@eslint/js": "^9.27.0",
"@playwright/test": "^1.51.1",
"@sentry/webpack-plugin": "^3.4.0",
"@sentry/webpack-plugin": "^3.5.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
@@ -108,7 +108,7 @@
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"jsdom": "^26.0.0",
"memfs": "^4.17.1",
"memfs": "^4.17.2",
"os-browserify": "^0.3.0",
"playwright": "^1.51.1",
"react-error-overlay": "^6.1.0",
@@ -120,7 +120,7 @@
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-style-import": "^2.0.0",
"vitest": "^3.1.3",
"vitest": "^3.1.4",
"workbox-window": "^7.3.0"
},
"engines": {
@@ -2587,9 +2587,9 @@
}
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.44.0.tgz",
"integrity": "sha512-18Aa+7KP/L2Kj9lxmT4EJZnsCq/xGIHgzU26rdzsKMhjpeT3YY+qin/dNAnIaVHPZnee7kXpZL55M9htd30r7Q==",
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.44.1.tgz",
"integrity": "sha512-j1QImCqf/XJmhIjC1OPpgiZV9g370HG9MNT9s/CDwCKsoYzNCPEKK+GfsidahJx7yIlBbm+4dPLlGec+bKn7oA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -2912,13 +2912,16 @@
}
},
"node_modules/@eslint/js": {
"version": "9.26.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz",
"integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==",
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz",
"integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
}
},
"node_modules/@fingerprintjs/fingerprintjs": {
@@ -2931,15 +2934,15 @@
}
},
"node_modules/@firebase/analytics": {
"version": "0.10.13",
"resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.13.tgz",
"integrity": "sha512-X+6wMOPgA9l0AeeMdMcMfaCP4XKPvrhx55MGuMrfHvUrOvFKldpzBum7KkoGJMoexKmqmKP+mCmJMY9Fb8K6Hw==",
"version": "0.10.16",
"resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.16.tgz",
"integrity": "sha512-cMtp19He7Fd6uaj/nDEul+8JwvJsN8aRSJyuA1QN3QrKvfDDp+efjVurJO61sJpkVftw9O9nNMdhFbRcTmTfRQ==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.6.14",
"@firebase/installations": "0.6.14",
"@firebase/component": "0.6.17",
"@firebase/installations": "0.6.17",
"@firebase/logger": "0.4.4",
"@firebase/util": "1.11.1",
"@firebase/util": "1.12.0",
"tslib": "^2.1.0"
},
"peerDependencies": {
@@ -2947,14 +2950,14 @@
}
},
"node_modules/@firebase/app": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.12.1.tgz",
"integrity": "sha512-ASExOlmmjRMdwOQ65Oj6R9JBqa7iiT1/LgZjtbU7FqxoJZNWHrt39NJ/z2bjyYDdAHX8jkY7muFqzahScCXgfA==",
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.13.0.tgz",
"integrity": "sha512-Vj3MST245nq+V5UmmfEkB3isIgPouyUr8yGJlFeL9Trg/umG5ogAvrjAYvQ8gV7daKDoQSRnJKWI2JFpQqRsuQ==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.6.14",
"@firebase/component": "0.6.17",
"@firebase/logger": "0.4.4",
"@firebase/util": "1.11.1",
"@firebase/util": "1.12.0",
"idb": "7.1.1",
"tslib": "^2.1.0"
},
@@ -2963,14 +2966,14 @@
}
},
"node_modules/@firebase/auth": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.2.tgz",
"integrity": "sha512-HHudcj3CJyXpoMKslNOVHGSNJdAUjvy5xBA/G/uPb32QFqvx5F3EW9RDYvve2IHEN7Vpc1QTkk/28J32x83UGA==",
"version": "1.10.5",
"resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.5.tgz",
"integrity": "sha512-6wF/NdMTwObL4RNQePunuzMr9O3gyftisvFZFFKf57D2HONXo87YymogRV8d+Z7SLA0rcNBN1gLJVk2D0y97gA==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.6.14",
"@firebase/component": "0.6.17",
"@firebase/logger": "0.4.4",
"@firebase/util": "1.11.1",
"@firebase/util": "1.12.0",
"tslib": "^2.1.0"
},
"engines": {
@@ -2987,12 +2990,12 @@
}
},
"node_modules/@firebase/component": {
"version": "0.6.14",
"resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.14.tgz",
"integrity": "sha512-kf/zAT8GQJ9nYoHuj0mv7twp1QzifKYrO+GsmsVHHM+Hi9KkmI7E3B3J0CtihHpb34vinl4gbJrYJ2p2wfvc9A==",
"version": "0.6.17",
"resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.17.tgz",
"integrity": "sha512-M6DOg7OySrKEFS8kxA3MU5/xc37fiOpKPMz6cTsMUcsuKB6CiZxxNAvgFta8HGRgEpZbi8WjGIj6Uf+TpOhyzg==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/util": "1.11.1",
"@firebase/util": "1.12.0",
"tslib": "^2.1.0"
},
"engines": {
@@ -3000,14 +3003,14 @@
}
},
"node_modules/@firebase/firestore": {
"version": "4.7.12",
"resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.7.12.tgz",
"integrity": "sha512-50KRdSp8xA7+G0wfWxlnCoEN951mt8BVdLMxeP57Rehj2DqIb41q6Fc6JH0dfQ4TlMqWua1YfVY1jPEAaHVF9w==",
"version": "4.7.15",
"resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.7.15.tgz",
"integrity": "sha512-FgWTmkNBEXdKCoN2ngBNjrMaXuBx6QwjiZZVnOGg+VjUmiBq5gAqlDIW5bZY6i/NYvLUrWugdqIs7y9GHEqwww==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.6.14",
"@firebase/component": "0.6.17",
"@firebase/logger": "0.4.4",
"@firebase/util": "1.11.1",
"@firebase/util": "1.12.0",
"@firebase/webchannel-wrapper": "1.0.3",
"@grpc/grpc-js": "~1.9.0",
"@grpc/proto-loader": "^0.7.8",
@@ -3021,13 +3024,13 @@
}
},
"node_modules/@firebase/installations": {
"version": "0.6.14",
"resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.14.tgz",
"integrity": "sha512-uE837g9+sv6PfjWPgOfG3JtjZ+hJ7KBHO4UVenVsvhzgOxFkvLjO/bgE7fyvsaD3fOHSXunx3adRIg4eUEMPyA==",
"version": "0.6.17",
"resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.17.tgz",
"integrity": "sha512-zfhqCNJZRe12KyADtRrtOj+SeSbD1H/K8J24oQAJVv/u02eQajEGlhZtcx9Qk7vhGWF5z9dvIygVDYqLL4o1XQ==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.6.14",
"@firebase/util": "1.11.1",
"@firebase/component": "0.6.17",
"@firebase/util": "1.12.0",
"idb": "7.1.1",
"tslib": "^2.1.0"
},
@@ -3048,15 +3051,15 @@
}
},
"node_modules/@firebase/messaging": {
"version": "0.12.18",
"resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.18.tgz",
"integrity": "sha512-2MGhUGoCZloB7ysoYzG/T2nnRmHYLT+AcqYouZuD6APabpkDhF8lHsmSQq4MFSlXhI3DKFOXxjuvbY8ec4C2JQ==",
"version": "0.12.21",
"resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.21.tgz",
"integrity": "sha512-bYJ2Evj167Z+lJ1ach6UglXz5dUKY1zrJZd15GagBUJSR7d9KfiM1W8dsyL0lDxcmhmA/sLaBYAAhF1uilwN0g==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.6.14",
"@firebase/installations": "0.6.14",
"@firebase/component": "0.6.17",
"@firebase/installations": "0.6.17",
"@firebase/messaging-interop-types": "0.2.3",
"@firebase/util": "1.11.1",
"@firebase/util": "1.12.0",
"idb": "7.1.1",
"tslib": "^2.1.0"
},
@@ -3071,9 +3074,9 @@
"license": "Apache-2.0"
},
"node_modules/@firebase/util": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.11.1.tgz",
"integrity": "sha512-RXg4WE8C2LUrvoV/TMGRTu223zZf9Dq9MR8yHZio9nF9TpLnpCPURw9VWWB2WATDl6HfIdWfl2x2SJYtHkN4hw==",
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.12.0.tgz",
"integrity": "sha512-Z4rK23xBCwgKDqmzGVMef+Vb4xso2j5Q8OG0vVL4m4fA5ZjPMYQazu8OJJC3vtQRC3SQ/Pgx/6TPNVsCd70QRw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -3836,9 +3839,9 @@
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.1.tgz",
"integrity": "sha512-GLjHS13LiBdiuxSJvfWs3+Cx5yt97mCbuVlDteTusS6VRksPhoWviO8L1e3Re1G94m6lkw/l4pjEEyyNaGf19g==",
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
"integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -4458,88 +4461,88 @@
"license": "MIT"
},
"node_modules/@sentry-internal/browser-utils": {
"version": "9.18.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.18.0.tgz",
"integrity": "sha512-TwSlmgYpHhe55JpOcVApkM0XcXZh1/cYuEPKPFgeaaPD8BrQrLJJvwKxnonSWXOhdnkJxi4GgK7j7mw57PS4aA==",
"version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.22.0.tgz",
"integrity": "sha512-Ou1tBnVxFAIn8i9gvrWzRotNJQYiu3awNXpsFCw6qFwmiKAVPa6b13vCdolhXnrIiuR77jY1LQnKh9hXpoRzsg==",
"license": "MIT",
"dependencies": {
"@sentry/core": "9.18.0"
"@sentry/core": "9.22.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "9.18.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.18.0.tgz",
"integrity": "sha512-QlrB8oQK+5bfhbgK6yHF6rLwLNJ9XuGblTc51yVkm4d4jn4W/HDyaNqMfQF+JXdTiFatl8oz2xdKR8kGK8kXyg==",
"version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.22.0.tgz",
"integrity": "sha512-zgMVkoC61fgi41zLcSZA59vOtKxcLrKBo1ECYhPD1hxEaneNqY5fhXDwlQBw96P5l2yqkgfX6YZtSdU4ejI9yA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "9.18.0"
"@sentry/core": "9.22.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "9.18.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.18.0.tgz",
"integrity": "sha512-2A32FFwrlZtdpBruvpcLEfucu6BpyqOk3F4Bo5smM/5q7u0pa7q5d9FSY5l3nwKEAFAoLGv3hcCb+8wxMm50xA==",
"version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.22.0.tgz",
"integrity": "sha512-9GOycoKbrclcRXfcbNV8svbmAsOS5R4wXBQmKF4pFLkmFA/lJv9kdZSNYkRvkrxdNfbMIJXP+DV9EqTZcryXig==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "9.18.0",
"@sentry/core": "9.18.0"
"@sentry-internal/browser-utils": "9.22.0",
"@sentry/core": "9.22.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "9.18.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.18.0.tgz",
"integrity": "sha512-3DEyQLmHcYgcwJ8n8eMhI6bhhawPuMc2xTT+Az8gXMqCO/X9ZACpipAmhXFjYP9Ptl+w0Vh3nllJw+gXc/DOsg==",
"version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.22.0.tgz",
"integrity": "sha512-EcG9IMSEalFe49kowBTJObWjof/iHteDwpyuAszsFDdQUYATrVUtwpwN7o52vDYWJud4arhjrQnMamIGxa79eQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "9.18.0",
"@sentry/core": "9.18.0"
"@sentry-internal/replay": "9.22.0",
"@sentry/core": "9.22.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/babel-plugin-component-annotate": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.4.0.tgz",
"integrity": "sha512-tSzfc3aE7m0PM0Aj7HBDet5llH9AB9oc+tBQ8AvOqUSnWodLrNCuWeQszJ7mIBovD3figgCU3h0cvI6U5cDtsg==",
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.5.0.tgz",
"integrity": "sha512-s2go8w03CDHbF9luFGtBHKJp4cSpsQzNVqgIa9Pfa4wnjipvrK6CxVT4icpLA3YO6kg5u622Yoa5GF3cJdippw==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/@sentry/browser": {
"version": "9.18.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.18.0.tgz",
"integrity": "sha512-0SWfp4J2+mH4lZOcHfyIwt9VoGD7yCGQE1cm0BPcLwKnrVQeXHtUXNYNy8HTHSjTGyoFDhEAYelE/tdA3OLcWQ==",
"version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.22.0.tgz",
"integrity": "sha512-3TeRm74dvX0JdjX0AgkQa+22iUHwHnY+Q6M05NZ+tDeCNHGK/mEBTeqquS1oQX67jWyuvYmG3VV6RJUxtG9Paw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "9.18.0",
"@sentry-internal/feedback": "9.18.0",
"@sentry-internal/replay": "9.18.0",
"@sentry-internal/replay-canvas": "9.18.0",
"@sentry/core": "9.18.0"
"@sentry-internal/browser-utils": "9.22.0",
"@sentry-internal/feedback": "9.22.0",
"@sentry-internal/replay": "9.22.0",
"@sentry-internal/replay-canvas": "9.22.0",
"@sentry/core": "9.22.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/bundler-plugin-core": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.4.0.tgz",
"integrity": "sha512-X1Q41AsQ6xcT6hB4wYmBDBukndKM/inT4IsR7pdKLi7ICpX2Qq6lisamBAEPCgEvnLpazSFguaiC0uiwMKAdqw==",
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.5.0.tgz",
"integrity": "sha512-zDzPrhJqAAy2VzV4g540qAZH4qxzisstK2+NIJPZUUKztWRWUV2cMHsyUtdctYgloGkLyGpZJBE3RE6dmP/xqQ==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.18.5",
"@sentry/babel-plugin-component-annotate": "3.4.0",
"@sentry/babel-plugin-component-annotate": "3.5.0",
"@sentry/cli": "2.42.2",
"dotenv": "^16.3.1",
"find-up": "^5.0.0",
@@ -4899,22 +4902,22 @@
}
},
"node_modules/@sentry/core": {
"version": "9.18.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.18.0.tgz",
"integrity": "sha512-kRVH8BqMiaU2FTHYa68zNlAloS43jl4XtIEHkLKVH/7gUtwRmM4Gqj8P7RTrZdO1Lo7ksYnGj+AG05Z09CRbOw==",
"version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.22.0.tgz",
"integrity": "sha512-ixvtKmPF42Y6ckGUbFlB54OWI75H2gO5UYHojO6eXFpS7xO3ZGgV/QH6wb40mWK+0w5XZ0233FuU9VpsuE6mKA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/react": {
"version": "9.18.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.18.0.tgz",
"integrity": "sha512-1cCLYZrZ2gu6H8eE83DC47kLf+pzD1Rim3dDoOEvwt1F5cD3K/DBeIhoHZaXqBeQxuVyHXOOLXSAC/CIuas5Aw==",
"version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.22.0.tgz",
"integrity": "sha512-mI43NnioBYdG5TiXqRlhV1feZs9bnrrl+k5HOHBK7VQtymaXO0fkcsRLZTkdSgLRLMJGasZuvVhq2xK+18QyWQ==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "9.18.0",
"@sentry/core": "9.18.0",
"@sentry/browser": "9.22.0",
"@sentry/core": "9.22.0",
"hoist-non-react-statics": "^3.3.2"
},
"engines": {
@@ -4925,12 +4928,12 @@
}
},
"node_modules/@sentry/vite-plugin": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-3.4.0.tgz",
"integrity": "sha512-pUFBGrKsHuc8K6A7B1wU2nx65n9aIzvTlcHX9yZ1qvjEO0cZFih0JCwu1Fcav/yrtT9RMN44L/ugu/kMBHQhjQ==",
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-3.5.0.tgz",
"integrity": "sha512-jUnpTdpicG8wefamw7eNo2uO+Q3KCbOAiF76xH4gfNHSW6TN2hBfOtmLu7J+ive4c0Al3+NEHz19bIPR0lkwWg==",
"license": "MIT",
"dependencies": {
"@sentry/bundler-plugin-core": "3.4.0",
"@sentry/bundler-plugin-core": "3.5.0",
"unplugin": "1.0.1"
},
"engines": {
@@ -4938,13 +4941,13 @@
}
},
"node_modules/@sentry/webpack-plugin": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-3.4.0.tgz",
"integrity": "sha512-i+nAxxniJV5ovijojjTF5n+Yj08Xk8my+vm8+oo0C0I7xcnI2gOKft6B0sJOq01CNbo85X5m/3/edL0PKoWE9w==",
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-3.5.0.tgz",
"integrity": "sha512-xvclj0QY2HyU7uJLzOlHSrZQBDwfnGKJxp8mmlU4L7CwmK+8xMCqlO7tYZoqE4K/wU3c2xpXql70x8qmvNMxzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sentry/bundler-plugin-core": "3.4.0",
"@sentry/bundler-plugin-core": "3.5.0",
"unplugin": "1.0.1",
"uuid": "^9.0.0"
},
@@ -5810,14 +5813,14 @@
}
},
"node_modules/@vitest/expect": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.3.tgz",
"integrity": "sha512-7FTQQuuLKmN1Ig/h+h/GO+44Q1IlglPlR2es4ab7Yvfx+Uk5xsv+Ykk+MEt/M2Yn/xGmzaLKxGw2lgy2bwuYqg==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.4.tgz",
"integrity": "sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.1.3",
"@vitest/utils": "3.1.3",
"@vitest/spy": "3.1.4",
"@vitest/utils": "3.1.4",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
@@ -5826,13 +5829,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.3.tgz",
"integrity": "sha512-PJbLjonJK82uCWHjzgBJZuR7zmAOrSvKk1QBxrennDIgtH4uK0TB1PvYmc0XBCigxxtiAVPfWtAdy4lpz8SQGQ==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.4.tgz",
"integrity": "sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.1.3",
"@vitest/spy": "3.1.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
@@ -5873,9 +5876,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.3.tgz",
"integrity": "sha512-i6FDiBeJUGLDKADw2Gb01UtUNb12yyXAqC/mmRWuYl+m/U9GS7s8us5ONmGkGpUUo7/iAYzI2ePVfOZTYvUifA==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.4.tgz",
"integrity": "sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5886,13 +5889,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.3.tgz",
"integrity": "sha512-Tae+ogtlNfFei5DggOsSUvkIaSuVywujMj6HzR97AHK6XK8i3BuVyIifWAm/sE3a15lF5RH9yQIrbXYuo0IFyA==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.4.tgz",
"integrity": "sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.1.3",
"@vitest/utils": "3.1.4",
"pathe": "^2.0.3"
},
"funding": {
@@ -5907,13 +5910,13 @@
"license": "MIT"
},
"node_modules/@vitest/snapshot": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.3.tgz",
"integrity": "sha512-XVa5OPNTYUsyqG9skuUkFzAeFnEzDp8hQu7kZ0N25B1+6KjGm4hWLtURyBbsIAOekfWQ7Wuz/N/XXzgYO3deWQ==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.4.tgz",
"integrity": "sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.1.3",
"@vitest/pretty-format": "3.1.4",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
@@ -5939,9 +5942,9 @@
"license": "MIT"
},
"node_modules/@vitest/spy": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.3.tgz",
"integrity": "sha512-x6w+ctOEmEXdWaa6TO4ilb7l9DxPR5bwEb6hILKuxfU1NqWT2mpJD9NJN7t3OTfxmVlOMrvtoFJGdgyzZ605lQ==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.4.tgz",
"integrity": "sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5952,13 +5955,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.3.tgz",
"integrity": "sha512-2Ltrpht4OmHO9+c/nmHtF09HWiyWdworqnHIwjfvDyWjuwKbdkcS9AnhsDn+8E2RM4x++foD1/tNuLPVvWG1Rg==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.4.tgz",
"integrity": "sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.1.3",
"@vitest/pretty-format": "3.1.4",
"loupe": "^3.1.3",
"tinyrainbow": "^2.0.0"
},
@@ -6088,9 +6091,9 @@
}
},
"node_modules/antd": {
"version": "5.25.1",
"resolved": "https://registry.npmjs.org/antd/-/antd-5.25.1.tgz",
"integrity": "sha512-4KC7KuPCjr0z3Vuw9DsF+ceqJaPLbuUI3lOX1sY8ix25ceamp+P8yxOmk3Y2JHCD2ZAhq+5IQ/DTJRN2adWYKQ==",
"version": "5.25.2",
"resolved": "https://registry.npmjs.org/antd/-/antd-5.25.2.tgz",
"integrity": "sha512-7R2nUvlHhey7Trx64+hCtGXOiy+DTUs1Lv5bwbV1LzEIZIhWb0at1AM6V3K108a5lyoR9n7DX3ptlLF7uYV/DQ==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^7.2.0",
@@ -6128,11 +6131,11 @@
"rc-rate": "~2.13.1",
"rc-resize-observer": "^1.4.3",
"rc-segmented": "~2.7.0",
"rc-select": "~14.16.7",
"rc-select": "~14.16.8",
"rc-slider": "~11.1.8",
"rc-steps": "~6.0.1",
"rc-switch": "~4.1.0",
"rc-table": "~7.50.4",
"rc-table": "~7.50.5",
"rc-tabs": "~15.6.1",
"rc-textarea": "~1.10.0",
"rc-tooltip": "~6.4.0",
@@ -11833,9 +11836,9 @@
}
},
"node_modules/memfs": {
"version": "4.17.1",
"resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.1.tgz",
"integrity": "sha512-thuTRd7F4m4dReCIy7vv4eNYnU6XI/tHMLSMMHLiortw/Y0QxqKtinG523U2aerzwYWGi606oBP4oMPy4+edag==",
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.2.tgz",
"integrity": "sha512-NgYhCOWgovOXSzvYgUW0LQ7Qy72rWQMGGFJDoWg4G30RHd3z77VbYdtJ4fembJXBy8pMIUA31XNAupobOQlwdg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -13569,9 +13572,9 @@
}
},
"node_modules/query-string": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.1.2.tgz",
"integrity": "sha512-s3UlTyjxRux4KjwWaJsjh1Mp8zoCkSGKirbD9H89pEM9UOZsfpRZpdfzvsy2/mGlLfC3NnYVpy2gk7jXITHEtA==",
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.2.0.tgz",
"integrity": "sha512-YIRhrHujoQxhexwRLxfy3VSjOXmvZRd2nyw1PwL1UUqZ/ys1dEZd1+NSgXkne2l/4X/7OXkigEAuhTX0g/ivJQ==",
"license": "MIT",
"dependencies": {
"decode-uri-component": "^0.4.1",
@@ -14024,9 +14027,9 @@
}
},
"node_modules/rc-select": {
"version": "14.16.7",
"resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.7.tgz",
"integrity": "sha512-lT9kO5gFHQdJzu9a0btcOtNaJHkhenSl8H5mcpgXN9VIMXP59rnkpbdHmPrteixWs1D5zFOTyoTYX3b7joADIQ==",
"version": "14.16.8",
"resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz",
"integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
@@ -14097,9 +14100,9 @@
}
},
"node_modules/rc-table": {
"version": "7.50.4",
"resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.50.4.tgz",
"integrity": "sha512-Y+YuncnQqoS5e7yHvfvlv8BmCvwDYDX/2VixTBEhkMDk9itS9aBINp4nhzXFKiBP/frG4w0pS9d9Rgisl0T1Bw==",
"version": "7.50.5",
"resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.50.5.tgz",
"integrity": "sha512-FDZu8aolhSYd3v9KOc3lZOVAU77wmRRu44R0Wfb8Oj1dXRUsloFaXMSl6f7yuWZUxArJTli7k8TEOX2mvhDl4A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
@@ -15375,9 +15378,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.88.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.88.0.tgz",
"integrity": "sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==",
"version": "1.89.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.89.0.tgz",
"integrity": "sha512-ld+kQU8YTdGNjOLfRWBzewJpU5cwEv/h5yyqlSeJcj6Yh8U4TDA9UA5FPicqDz/xgRPWRSYIQNiFks21TbA9KQ==",
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
@@ -17533,9 +17536,9 @@
}
},
"node_modules/vite-node": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.3.tgz",
"integrity": "sha512-uHV4plJ2IxCl4u1up1FQRrqclylKAogbtBfOTwcuJ28xFi+89PZ57BRh+naIRvH70HPwxy5QHYzg1OrEaC7AbA==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.4.tgz",
"integrity": "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -17731,19 +17734,19 @@
}
},
"node_modules/vitest": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.3.tgz",
"integrity": "sha512-188iM4hAHQ0km23TN/adso1q5hhwKqUpv+Sd6p5sOuh6FhQnRNW3IsiIpvxqahtBabsJ2SLZgmGSpcYK4wQYJw==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.4.tgz",
"integrity": "sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "3.1.3",
"@vitest/mocker": "3.1.3",
"@vitest/pretty-format": "^3.1.3",
"@vitest/runner": "3.1.3",
"@vitest/snapshot": "3.1.3",
"@vitest/spy": "3.1.3",
"@vitest/utils": "3.1.3",
"@vitest/expect": "3.1.4",
"@vitest/mocker": "3.1.4",
"@vitest/pretty-format": "^3.1.4",
"@vitest/runner": "3.1.4",
"@vitest/snapshot": "3.1.4",
"@vitest/spy": "3.1.4",
"@vitest/utils": "3.1.4",
"chai": "^5.2.0",
"debug": "^4.4.0",
"expect-type": "^1.2.1",
@@ -17756,7 +17759,7 @@
"tinypool": "^1.0.2",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0",
"vite-node": "3.1.3",
"vite-node": "3.1.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
@@ -17772,8 +17775,8 @@
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.1.3",
"@vitest/ui": "3.1.3",
"@vitest/browser": "3.1.4",
"@vitest/ui": "3.1.4",
"happy-dom": "*",
"jsdom": "*"
},

View File

@@ -12,19 +12,19 @@
"@apollo/client": "^3.13.6",
"@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.6.1",
"@firebase/analytics": "^0.10.13",
"@firebase/app": "^0.12.1",
"@firebase/auth": "^1.10.2",
"@firebase/firestore": "^4.7.12",
"@firebase/messaging": "^0.12.18",
"@firebase/analytics": "^0.10.16",
"@firebase/app": "^0.13.0",
"@firebase/auth": "^1.10.5",
"@firebase/firestore": "^4.7.15",
"@firebase/messaging": "^0.12.21",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.8.1",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.45.0",
"@sentry/react": "^9.18.0",
"@sentry/vite-plugin": "^3.4.0",
"@sentry/react": "^9.22.0",
"@sentry/vite-plugin": "^3.5.0",
"@splitsoftware/splitio-react": "^2.1.1",
"@tanem/react-nprogress": "^5.0.53",
"antd": "^5.25.1",
"antd": "^5.25.2",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.3.0",
"autosize": "^6.0.1",
@@ -48,7 +48,7 @@
"normalize-url": "^8.0.1",
"object-hash": "^3.0.0",
"prop-types": "^15.8.1",
"query-string": "^9.1.2",
"query-string": "^9.2.0",
"raf-schd": "^4.0.3",
"react": "^18.3.1",
"react-big-calendar": "^1.18.0",
@@ -77,7 +77,7 @@
"redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"sass": "^1.88.0",
"sass": "^1.89.0",
"socket.io-client": "^4.8.1",
"styled-components": "^6.1.18",
"subscriptions-transport-ws": "^0.11.0",
@@ -130,12 +130,12 @@
"@ant-design/icons": "^6.0.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.27.1",
"@dotenvx/dotenvx": "^1.44.0",
"@dotenvx/dotenvx": "^1.44.1",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.26.0",
"@eslint/js": "^9.27.0",
"@playwright/test": "^1.51.1",
"@sentry/webpack-plugin": "^3.4.0",
"@sentry/webpack-plugin": "^3.5.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
@@ -148,7 +148,7 @@
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"jsdom": "^26.0.0",
"memfs": "^4.17.1",
"memfs": "^4.17.2",
"os-browserify": "^0.3.0",
"playwright": "^1.51.1",
"react-error-overlay": "^6.1.0",
@@ -160,7 +160,7 @@
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-style-import": "^2.0.0",
"vitest": "^3.1.3",
"vitest": "^3.1.4",
"workbox-window": "^7.3.0"
}
}

View File

@@ -34,16 +34,14 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) {
SubscribeToTopicForFCMNotification();
//Register WS handlers
// Register WebSocket handlers
if (socket && socket.connected) {
registerMessagingHandlers({ socket, client });
}
return () => {
if (socket && socket.connected) {
return () => {
unregisterMessagingHandlers({ socket });
}
};
};
}
}, [bodyshop, socket, t, client]);
if (!bodyshop || !bodyshop.messagingservicesid) return <></>;

View File

@@ -1,5 +1,5 @@
import { Badge, Card, List, Space, Tag } from "antd";
import React, { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { connect } from "react-redux";
import { Virtuoso } from "react-virtuoso";
import { createStructuredSelector } from "reselect";
@@ -10,35 +10,60 @@ import PhoneFormatter from "../../utils/PhoneFormatter";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import _ from "lodash";
import "./chat-conversation-list.styles.scss";
import { useQuery } from "@apollo/client";
import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries.js";
import { phone } from "phone";
import { useTranslation } from "react-i18next";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation
selectedConversation: selectSelectedConversation,
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId))
});
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) {
// That comma is there for a reason, do not remove it
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) {
const { t } = useTranslation();
const [, forceUpdate] = useState(false);
// Re-render every minute
const phoneNumbers = conversationList.map((item) => phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""));
const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, {
variables: {
bodyshopid: bodyshop.id,
phone_numbers: phoneNumbers
},
skip: !conversationList.length,
fetchPolicy: "cache-and-network"
});
const optOutMap = useMemo(() => {
const map = new Map();
optOutData?.phone_number_opt_out?.forEach((optOut) => {
map.set(optOut.phone_number, true);
});
return map;
}, [optOutData?.phone_number_opt_out]);
useEffect(() => {
const interval = setInterval(() => {
forceUpdate((prev) => !prev); // Toggle state to trigger re-render
}, 60000); // 1 minute in milliseconds
return () => clearInterval(interval); // Cleanup on unmount
forceUpdate((prev) => !prev);
}, 60000);
return () => clearInterval(interval);
}, []);
// Memoize the sorted conversation list
const sortedConversationList = React.useMemo(() => {
const sortedConversationList = useMemo(() => {
return _.orderBy(conversationList, ["updated_at"], ["desc"]);
}, [conversationList]);
const renderConversation = (index) => {
const renderConversation = (index, t) => {
const item = sortedConversationList[index];
const normalizedPhone = phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
const hasOptOutEntry = optOutMap.has(normalizedPhone);
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
const cardContentLeft =
item.job_conversations.length > 0
@@ -60,7 +85,12 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
</>
);
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count} />;
const cardExtra = (
<>
<Badge count={item.messages_aggregate.aggregate.count} />
{hasOptOutEntry && <Tag color="red">{t("messaging.labels.no_consent")}</Tag>}
</>
);
const getCardStyle = () =>
item.id === selectedConversation
@@ -73,9 +103,25 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
onClick={() => setSelectedConversation(item.id)}
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
>
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
<Card style={getCardStyle()} variant={true} size="small" extra={cardExtra} title={cardTitle}>
<div
style={{
display: "inline-block",
width: "70%",
textAlign: "left"
}}
>
{cardContentLeft}
</div>
<div
style={{
display: "inline-block",
width: "30%",
textAlign: "right"
}}
>
{cardContentRight}
</div>
</Card>
</List.Item>
);
@@ -85,7 +131,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
<div className="chat-list-container">
<Virtuoso
data={sortedConversationList}
itemContent={(index) => renderConversation(index)}
itemContent={(index) => renderConversation(index, t)}
style={{ height: "100%", width: "100%" }}
/>
</div>

View File

@@ -24,7 +24,7 @@
/* Add spacing and better alignment for items */
.chat-list-item {
padding: 0.5rem 0; /* Add spacing between list items */
padding: 0.2rem 0; /* Add spacing between list items */
.ant-card {
border-radius: 8px; /* Slight rounding for card edges */

View File

@@ -37,7 +37,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
variables: {
jobId: conversation.job_conversations[0] && conversation.job_conversations[0].jobid
jobId: conversation.job_conversations[0] && conversation.job_conversations[0]?.jobid
},
skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0
@@ -67,14 +67,14 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
<>
{!bodyshop.uselocalmediaserver && (
<JobsDocumentImgproxyGalleryExternal
jobId={conversation.job_conversations[0].jobid}
jobId={conversation.job_conversations[0]?.jobid}
externalMediaState={[selectedMedia, setSelectedMedia]}
/>
)}
{bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0]?.jobid}
/>
)}
</>
@@ -89,7 +89,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
{bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0]?.jobid}
/>
)}
</>

View File

@@ -1,5 +1,5 @@
import { LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Input, Spin } from "antd";
import { Alert, Input, Spin } from "antd";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
@@ -10,6 +10,9 @@ import { selectIsSending, selectMessage } from "../../redux/messaging/messaging.
import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component";
import ChatPresetsComponent from "../chat-presets/chat-presets.component";
import { useQuery } from "@apollo/client";
import { phone } from "phone";
import { GET_PHONE_NUMBER_OPT_OUT } from "../../graphql/phone-number-opt-out.queries";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -25,16 +28,24 @@ const mapDispatchToProps = (dispatch) => ({
function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) {
const inputArea = useRef(null);
const [selectedMedia, setSelectedMedia] = useState([]);
const { t } = useTranslation();
const normalizedPhone = phone(conversation.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUT, {
variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone },
fetchPolicy: "cache-and-network"
});
const isOptedOut = !!optOutData?.phone_number_opt_out?.[0];
useEffect(() => {
inputArea.current.focus();
}, [isSending, setMessage]);
const { t } = useTranslation();
const handleEnter = () => {
const selectedImages = selectedMedia.filter((i) => i.isSelected);
if ((message === "" || !message) && selectedImages.length === 0) return;
if (isOptedOut) return; // Prevent sending if phone number is opted out
logImEXEvent("messaging_send_message");
if (selectedImages.length < 11) {
@@ -44,7 +55,8 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
messagingServiceSid: bodyshop.messagingservicesid,
conversationid: conversation.id,
selectedMedia: selectedImages,
imexshopid: bodyshop.imexshopid
imexshopid: bodyshop.imexshopid,
bodyshopid: bodyshop.id
};
sendMessage(newMessage);
setSelectedMedia(
@@ -57,6 +69,7 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
return (
<div className="imex-flex-row" style={{ width: "100%" }}>
{isOptedOut && <Alert message={t("messaging.errors.no_consent")} type="warning" style={{ marginBottom: 8 }} />}
<ChatPresetsComponent className="imex-flex-row__margin" />
<ChatMediaSelector
conversation={conversation}
@@ -71,18 +84,18 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
ref={inputArea}
autoSize={{ minRows: 1, maxRows: 4 }}
value={message}
disabled={isSending}
disabled={isSending || isOptedOut}
placeholder={t("messaging.labels.typeamessage")}
onChange={(e) => setMessage(e.target.value)}
onPressEnter={(event) => {
event.preventDefault();
if (!!!event.shiftKey) handleEnter();
if (!event.shiftKey && !isOptedOut) handleEnter();
}}
/>
</span>
<SendOutlined
className="chat-send-message-button"
// disabled={message === "" || !message}
disabled={isOptedOut || message === "" || !message}
onClick={handleEnter}
/>
<Spin

View File

@@ -81,8 +81,9 @@ export function HasFeatureAccess({ featureName, bodyshop, bypass, debug = false
}
return (
bodyshop?.features?.allAccess ||
bodyshop?.features?.[featureName] ||
dayjs(bodyshop?.features[featureName]).isAfter(dayjs())
(typeof bodyshop?.features?.[featureName] === "boolean"
? bodyshop?.features?.[featureName]
: dayjs(bodyshop?.features?.[featureName]).isAfter(dayjs()))
);
}

View File

@@ -15,6 +15,7 @@ import {
HomeFilled,
ImportOutlined,
LineChartOutlined,
OneToOneOutlined,
PaperClipOutlined,
PhoneOutlined,
PlusCircleOutlined,
@@ -24,6 +25,7 @@ import {
TeamOutlined,
ToolFilled,
UnorderedListOutlined,
UsergroupAddOutlined,
UserOutlined
} from "@ant-design/icons";
import { useQuery } from "@apollo/client";
@@ -40,6 +42,7 @@ import { RiSurveyLine } from "react-icons/ri";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions";
@@ -47,11 +50,10 @@ import { signOutStart } from "../../redux/user/user.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import day from "../../utils/day.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
// Redux mappings
const mapStateToProps = createStructuredSelector({
@@ -642,17 +644,32 @@ function Header({
label: t("menus.header.help"),
onClick: () => window.open("https://help.imex.online/", "_blank")
},
...(InstanceRenderManager({ imex: true, rome: false })
? [
{
key: "rescue",
id: "header-rescue",
icon: <CarFilled />,
label: t("menus.header.rescueme"),
onClick: () => window.open("https://imexrescue.com/", "_blank")
}
]
: []),
{
key: "remoteassist",
id: "header-remote-assist",
icon: <OneToOneOutlined />,
label: t("menus.header.remoteassist"),
children: [
...(InstanceRenderManager({ imex: true, rome: true })
? [
{
key: "rescue",
id: "header-rescue",
icon: <PlusCircleOutlined />,
label: t("menus.header.rescueme"),
onClick: () => window.open("https://imexrescue.com/", "_blank")
}
]
: []),
{
key: "rescue",
id: "header-rescue-zoho",
icon: <UsergroupAddOutlined />,
label: t("menus.header.rescuemezoho"),
onClick: () => window.open("https://join.zoho.com/", "_blank")
}
]
},
{
key: "shiftclock",
id: "header-shiftclock",

View File

@@ -0,0 +1,62 @@
import { useQuery } from "@apollo/client";
import { Input, Table } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { TimeAgoFormatter } from "../../utils/DateFormatter";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const mapDispatchToProps = () => ({});
function PhoneNumberConsentList({ bodyshop, currentUser }) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const { loading, data } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, {
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined },
fetchPolicy: "network-only"
});
const columns = [
{
title: t("consent.phone_number"),
dataIndex: "phone_number",
render: (text) => <PhoneNumberFormatter>{text}</PhoneNumberFormatter>,
sorter: (a, b) => a.phone_number.localeCompare(b.phone_number)
},
{
title: t("consent.created_at"),
dataIndex: "created_at",
render: (text) => <TimeAgoFormatter>{text}</TimeAgoFormatter>,
sorter: (a, b) => new Date(a.created_at) - new Date(b.created_at)
}
];
return (
<div>
<Input.Search
placeholder={t("general.labels.search")}
onSearch={(value) => setSearch(value)}
style={{ marginBottom: 16 }}
/>
<Table
columns={columns}
dataSource={data?.phone_number_opt_out}
loading={loading}
rowKey="id"
style={{ marginTop: 16 }}
/>
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(PhoneNumberConsentList);

View File

@@ -20,7 +20,7 @@ const Board = ({ id, className, orientation, cardSettings, ...additionalProps })
default:
return cardSizesVertical.small;
}
}, [cardSettings]);
}, [cardSettings?.cardSize]);
return (
<>

View File

@@ -101,11 +101,33 @@ const BoardContainer = ({
async ({ draggableId, type, source, reason, mode, destination, combine }) => {
setIsDragging(false);
// Only update drag time if it's a valid drop with a different destination
if (type === "lane" && source && destination && !isEqual(source, destination)) {
setDragTime(source.droppableId);
setIsProcessing(true);
// Validate drag type and source
if (type !== "lane" || !source) {
// Invalid drag type or missing source, attempt to revert if possible
if (source) {
dispatch(
actions.moveCardAcrossLanes({
fromLaneId: source.droppableId,
toLaneId: source.droppableId,
cardId: draggableId,
index: source.index
})
);
}
setIsProcessing(false);
try {
await onDragEnd({ draggableId, type, source, reason, mode, destination, combine });
} catch (err) {
console.error("Error in onLaneDrag for invalid drag type or source", err);
}
return;
}
setDragTime(source.droppableId);
setIsProcessing(true);
// Handle valid drop to a different lane or position
if (destination && !isEqual(source, destination)) {
dispatch(
actions.moveCardAcrossLanes({
fromLaneId: source.droppableId,
@@ -114,14 +136,33 @@ const BoardContainer = ({
index: destination.index
})
);
} else {
// Same-lane drop or no destination, revert to original position
dispatch(
actions.moveCardAcrossLanes({
fromLaneId: source.droppableId,
toLaneId: source.droppableId,
cardId: draggableId,
index: source.index
})
);
}
try {
await onDragEnd({ draggableId, type, source, reason, mode, destination, combine });
} catch (err) {
console.error("Error in onLaneDrag", err);
} finally {
setIsProcessing(false);
}
try {
await onDragEnd({ draggableId, type, source, reason, mode, destination, combine });
} catch (err) {
console.error("Error in onLaneDrag", err);
// Ensure revert on error
dispatch(
actions.moveCardAcrossLanes({
fromLaneId: source.droppableId,
toLaneId: source.droppableId,
cardId: draggableId,
index: source.index
})
);
} finally {
setIsProcessing(false);
}
},
[dispatch, onDragEnd, setDragTime]

View File

@@ -133,7 +133,9 @@ const Lane = ({
Item: ItemComponent
},
itemContent: (index, item) => <ItemWrapper>{renderDraggable(index, item)}</ItemWrapper>,
overscan: { main: 10, reverse: 10 }
overscan: { main: 10, reverse: 10 },
// Ensure a minimum height for empty lanes to allow dropping
style: renderedCards.length === 0 ? { minHeight: "5px" } : {}
};
const horizontalProps = {
@@ -149,8 +151,6 @@ const Lane = ({
const componentProps = orientation === "vertical" ? verticalProps : horizontalProps;
// If the lane is collapsed, we want to render a div instead of the virtualized list, and we want to set the height to the max height of the lane so that
// the lane doesn't shrink when collapsed (in horizontal mode)
const finalComponentProps = collapsed
? orientation === "horizontal"
? {
@@ -161,9 +161,8 @@ const Lane = ({
: {}
: componentProps;
// If the lane is horizontal and collapsed, we want to render a placeholder so that the lane doesn't shrink to 0 height and grows when
// a card is dragged over it
const shouldRenderPlaceholder = orientation !== "horizontal" && (collapsed || renderedCards.length === 0);
// Always render placeholder for empty lanes in vertical mode to ensure droppable area
const shouldRenderPlaceholder = orientation === "vertical" ? collapsed || renderedCards.length === 0 : collapsed;
return (
<HeightMemoryWrapper
@@ -178,8 +177,8 @@ const Lane = ({
override={orientation !== "horizontal" && (collapsed || !renderedCards.length)}
>
<div
ref={laneRef} // Ensure laneRef is set here
style={{ height: "100%", width: "100%" }} // Make it scrollable
ref={laneRef}
style={{ height: "100%", width: "100%" }}
className={`react-trello-lane ${collapsed ? "lane-collapsed" : ""}`}
>
<div {...provided.droppableProps} ref={provided.innerRef} style={{ ...provided.droppableProps.style }}>

View File

@@ -6,6 +6,7 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { QUERY_ACTIVE_EMPLOYEES, QUERY_ACTIVE_EMPLOYEES_WITH_EMAIL } from "../../graphql/employees.queries";
import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries";
import { selectReportCenter } from "../../redux/modals/modals.selectors";
@@ -18,11 +19,10 @@ import EmployeeSearchSelectEmail from "../employee-search-select/employee-search
import EmployeeSearchSelect from "../employee-search-select/employee-search-select.component";
import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import ReportCenterModalFiltersSortersComponent from "./report-center-modal-filters-sorters-component";
import "./report-center-modal.styles.scss";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
reportCenterModal: selectReportCenter,
@@ -389,5 +389,7 @@ const restrictedReports = [
{ key: "job_costing_ro_date_detail", days: 183 },
{ key: "job_costing_ro_estimator", days: 183 },
{ key: "job_lifecycle_date_detail", days: 183 },
{ key: "job_lifecycle_date_summary", days: 183 }
{ key: "job_lifecycle_date_summary", days: 183 },
{ key: "customer_list", days: 183 },
{ key: "customer_list_excel", days: 183 }
];

View File

@@ -0,0 +1,25 @@
import { Typography } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import PhoneNumberConsentList from "../phone-number-consent/phone-number-consent.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({});
function ShopInfoConsentComponent({ bodyshop }) {
const { t } = useTranslation();
return (
<div>
<Typography.Title level={4}>{t("settings.title")}</Typography.Title>
{<PhoneNumberConsentList bodyshop={bodyshop} />}
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoConsentComponent);

View File

@@ -0,0 +1,28 @@
import { gql } from "@apollo/client";
export const GET_PHONE_NUMBER_OPT_OUT = gql`
query GET_PHONE_NUMBER_OPT_OUT($bodyshopid: uuid!, $phone_number: String!) {
phone_number_opt_out(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
id
bodyshopid
phone_number
created_at
updated_at
}
}
`;
export const GET_PHONE_NUMBER_OPT_OUTS = gql`
query GET_PHONE_NUMBER_OPT_OUTS($bodyshopid: uuid!, $search: String) {
phone_number_opt_out(
where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _ilike: $search } }
order_by: [{ phone_number: asc }, { updated_at: desc }]
) {
id
bodyshopid
phone_number
created_at
updated_at
}
}
`;

View File

@@ -10,10 +10,10 @@ import ShopCsiConfig from "../../components/shop-csi-config/shop-csi-config.comp
import ShopEmployeesContainer from "../../components/shop-employees/shop-employees.container";
import ShopInfoContainer from "../../components/shop-info/shop-info.container";
import ShopInfoUsersComponent from "../../components/shop-users/shop-users.component";
import ShopInfoConsentComponent from "../../components/shop-info/shop-info.consent.component";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component";
import ShopTeamsContainer from "../../components/shop-teams/shop-teams.container";
@@ -91,6 +91,14 @@ export function ShopPage({ bodyshop, setSelectedHeader, setBreadcrumbs }) {
children: <ShopCsiConfig />
});
}
// Add Consent Settings tab
items.push({
key: "consent",
label: t("bodyshop.labels.consent_settings"),
children: <ShopInfoConsentComponent bodyshop={bodyshop} />
});
return (
<RbacWrapper action="shop:config">
<Tabs activeKey={search.tab} onChange={(key) => history({ search: `?tab=${key}` })} items={items} />

View File

@@ -105,7 +105,6 @@ const userReducer = (state = INITIAL_STATE, action) => {
...action.payload //Spread current user details in.
}
};
case UserActionTypes.SET_SHOP_DETAILS:
return {
...state,
@@ -126,6 +125,7 @@ const userReducer = (state = INITIAL_STATE, action) => {
...state,
imexshopid: action.payload
};
default:
return state;
}

View File

@@ -335,20 +335,12 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
}
try {
InstanceRenderManager({
executeFunction: true,
args: [],
imex: () => {
window.$crisp.push(["set", "user:company", [payload.shopname]]);
window.$crisp.push(["set", "session:segments", [[`region:${payload.region_config}`]]]);
if (authRecord[0] && authRecord[0].user.validemail) {
window.$crisp.push(["set", "user:email", [authRecord[0].user.email]]);
}
},
rome: () => {
window.$zoho.salesiq.visitor.info({ "Shop Name": payload.shopname });
}
});
window.$crisp.push(["set", "user:company", [payload.shopname]]);
window.$crisp.push(["set", "session:segments", [[`region:${payload.region_config}`]]]);
if (authRecord[0] && authRecord[0].user.validemail) {
window.$crisp.push(["set", "user:email", [authRecord[0].user.email]]);
}
payload.features?.allAccess === true
? window.$crisp.push(["set", "session:segments", [["allAccess"]]])
: (() => {
@@ -359,6 +351,14 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
);
window.$crisp.push(["set", "session:segments", [["basic", ...featureKeys]]]);
})();
InstanceRenderManager({
executeFunction: true,
args: [],
rome: () => {
window.$zoho.salesiq.visitor.info({ "Shop Name": payload.shopname });
}
});
} catch (error) {
console.warn("Couldnt find $crisp.", error.message);
}

View File

@@ -656,6 +656,7 @@
}
},
"labels": {
"consent_settings": "Phone Number Opt-Out List",
"2tiername": "Name => RO",
"2tiersetup": "2 Tier Setup",
"2tiersource": "Source => RO",
@@ -2301,8 +2302,10 @@
"productionlist": "Production Board - List",
"readyjobs": "Ready Jobs",
"recent": "Recent Items",
"remoteassist": "Remote Assist",
"reportcenter": "Report Center",
"rescueme": "Rescue me!",
"rescueme": "Rescue Me!",
"rescuemezoho": "Remote Me In!",
"schedule": "Schedule",
"scoreboard": "Scoreboard",
"search": {
@@ -2377,7 +2380,8 @@
"errors": {
"invalidphone": "The phone number is invalid. Unable to open conversation. ",
"noattachedjobs": "No Jobs have been associated to this conversation. ",
"updatinglabel": "Error updating label. {{error}}"
"updatinglabel": "Error updating label. {{error}}",
"no_consent": "This phone number has not consented to receive messages."
},
"labels": {
"addlabel": "Add a label to this conversation.",
@@ -2393,7 +2397,8 @@
"selectmedia": "Select Media",
"sentby": "Sent by {{by}} at {{time}}",
"typeamessage": "Send a message...",
"unarchive": "Unarchive"
"unarchive": "Unarchive",
"no_consent": "No Consent"
},
"render": {
"conversation_list": "Conversation List"
@@ -3099,6 +3104,7 @@
"credits_not_received_date_vendorid": "Credits not Received by Vendor",
"csi": "CSI Responses",
"customer_list": "Customer List",
"customer_list_excel": "Customer List - Excel",
"cycle_time_analysis": "Cycle Time Analysis",
"estimates_written_converted": "Estimates Written/Converted",
"estimator_detail": "Jobs by Estimator (Detail)",
@@ -3862,6 +3868,14 @@
"validation": {
"unique_vendor_name": "You must enter a unique vendor name."
}
},
"consent": {
"phone_number": "Phone Number",
"status": "Consent Status",
"created_at": "Created At"
},
"settings": {
"title": "Phone Number Opt-Out List"
}
}
}

View File

@@ -2301,8 +2301,10 @@
"productionlist": "",
"readyjobs": "",
"recent": "",
"remoteassist": "",
"reportcenter": "",
"rescueme": "",
"rescuemezoho": "",
"schedule": "Programar",
"scoreboard": "",
"search": {
@@ -2498,7 +2500,8 @@
},
"tooltips": {
"job-watchers": "",
"not-employee": ""
"not-employee": "",
"not-employee-notifications": ""
}
},
"owner": {
@@ -3100,6 +3103,7 @@
"credits_not_received_date_vendorid": "",
"csi": "",
"customer_list": "",
"customer_list_excel": "",
"cycle_time_analysis": "",
"estimates_written_converted": "",
"estimator_detail": "",

View File

@@ -2301,8 +2301,10 @@
"productionlist": "",
"readyjobs": "",
"recent": "",
"remoteassist": "",
"reportcenter": "",
"rescueme": "",
"rescuemezoho": "",
"schedule": "Programme",
"scoreboard": "",
"search": {
@@ -2498,7 +2500,8 @@
},
"tooltips": {
"job-watchers": "",
"not-employee": ""
"not-employee": "",
"not-employee-notifications": ""
}
},
"owner": {
@@ -3100,6 +3103,7 @@
"credits_not_received_date_vendorid": "",
"csi": "",
"customer_list": "",
"customer_list_excel": "",
"cycle_time_analysis": "",
"estimates_written_converted": "",
"estimator_detail": "",

View File

@@ -2004,6 +2004,18 @@ export const TemplateList = (type, context) => {
},
group: "customers"
},
customer_list_excel: {
title: i18n.t("reportcenter.templates.customer_list_excel"),
subject: i18n.t("reportcenter.templates.customer_list_excel"),
key: "customer_list_excel",
reporttype: "excel",
disabled: false,
rangeFilter: {
object: i18n.t("reportcenter.labels.objects.jobs"),
field: i18n.t("jobs.fields.date_invoiced")
},
group: "customers"
},
exported_gsr_by_ro: {
title: i18n.t("reportcenter.templates.exported_gsr_by_ro"),
subject: i18n.t("reportcenter.templates.exported_gsr_by_ro"),
@@ -2241,7 +2253,7 @@ export const TemplateList = (type, context) => {
field: i18n.t("bills.fields.date")
},
group: "purchases"
},
}
}
: {}),
...(!type || type === "courtesycarcontract"

View File

@@ -2681,6 +2681,9 @@
- active:
_eq: true
allow_aggregations: true
- table:
name: integration_log
schema: public
- table:
name: inventory
schema: public
@@ -5861,6 +5864,32 @@
template_engine: Kriti
url: '{{$base_url}}/opensearch'
version: 2
- table:
name: phone_number_opt_out
schema: public
object_relationships:
- name: bodyshop
using:
foreign_key_constraint_on: bodyshopid
select_permissions:
- role: user
permission:
columns:
- phone_number
- created_at
- updated_at
- bodyshopid
- id
filter:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
comment: ""
- table:
name: phonebook
schema: public

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."bodyshops" add column "enforce_sms_consent" boolean
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "enforce_sms_consent" boolean
null;

View File

@@ -0,0 +1 @@
DROP TABLE "public"."phone_number_consent";

View File

@@ -0,0 +1,2 @@
CREATE TABLE "public"."phone_number_consent" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "bodyshopid" uuid NOT NULL, "phone_number" text NOT NULL, "consent_status" boolean NOT NULL, "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), "consent_updated_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("id") , FOREIGN KEY ("bodyshopid") REFERENCES "public"."bodyshops"("id") ON UPDATE restrict ON DELETE restrict, UNIQUE ("id"), UNIQUE ("bodyshopid", "phone_number"));
CREATE EXTENSION IF NOT EXISTS pgcrypto;

View File

@@ -0,0 +1 @@
DROP TABLE "public"."phone_number_consent_history";

View File

@@ -0,0 +1,2 @@
CREATE TABLE "public"."phone_number_consent_history" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "phone_number_consent_id" uuid NOT NULL, "old_value" boolean NOT NULL, "new_value" boolean NOT NULL, "reason" text NOT NULL, "changed_at" timestamptz NOT NULL DEFAULT now(), "changed_by" text NOT NULL, PRIMARY KEY ("id") , FOREIGN KEY ("phone_number_consent_id") REFERENCES "public"."phone_number_consent"("id") ON UPDATE restrict ON DELETE restrict, UNIQUE ("id"));
CREATE EXTENSION IF NOT EXISTS pgcrypto;

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_consent_history" alter column "old_value" set not null;

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_consent_history" alter column "old_value" drop not null;

View File

@@ -0,0 +1,3 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- DROP table "public"."phone_number_consent_history";

View File

@@ -0,0 +1 @@
DROP table "public"."phone_number_consent_history";

View File

@@ -0,0 +1,2 @@
alter table "public"."phone_number_consent" alter column "consent_status" drop not null;
alter table "public"."phone_number_consent" add column "consent_status" bool;

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_consent" drop column "consent_status" cascade;

View File

@@ -0,0 +1,3 @@
alter table "public"."phone_number_consent" alter column "consent_updated_at" set default now();
alter table "public"."phone_number_consent" alter column "consent_updated_at" drop not null;
alter table "public"."phone_number_consent" add column "consent_updated_at" timestamptz;

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_consent" drop column "consent_updated_at" cascade;

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_opt_out" rename to "phone_number_consent";

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_consent" rename to "phone_number_opt_out";

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" alter column "enforce_sms_consent" drop not null;
alter table "public"."bodyshops" add column "enforce_sms_consent" bool;

View File

@@ -0,0 +1 @@
alter table "public"."bodyshops" drop column "enforce_sms_consent" cascade;

View File

@@ -0,0 +1 @@
DROP TABLE "public"."integration_log";

View File

@@ -0,0 +1,18 @@
CREATE TABLE "public"."integration_log" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), "bodyshopid" uuid NOT NULL, "email" text NOT NULL, "jobid" uuid, "billid" uuid, "paymentid" uuid, "method" text NOT NULL, "name" text NOT NULL, "status" text NOT NULL, "platform" text NOT NULL, PRIMARY KEY ("id") , FOREIGN KEY ("bodyshopid") REFERENCES "public"."bodyshops"("id") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("email") REFERENCES "public"."users"("email") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("billid") REFERENCES "public"."bills"("id") ON UPDATE restrict ON DELETE set null, FOREIGN KEY ("jobid") REFERENCES "public"."jobs"("id") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("paymentid") REFERENCES "public"."payments"("id") ON UPDATE restrict ON DELETE set null);
CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"()
RETURNS TRIGGER AS $$
DECLARE
_new record;
BEGIN
_new := NEW;
_new."updated_at" = NOW();
RETURN _new;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "set_public_integration_log_updated_at"
BEFORE UPDATE ON "public"."integration_log"
FOR EACH ROW
EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"();
COMMENT ON TRIGGER "set_public_integration_log_updated_at" ON "public"."integration_log"
IS 'trigger to set value of column "updated_at" to current timestamp on row update';
CREATE EXTENSION IF NOT EXISTS pgcrypto;

1461
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,14 +16,14 @@
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.808.0",
"@aws-sdk/client-elasticache": "^3.808.0",
"@aws-sdk/client-s3": "^3.808.0",
"@aws-sdk/client-secrets-manager": "^3.808.0",
"@aws-sdk/client-ses": "^3.808.0",
"@aws-sdk/credential-provider-node": "^3.808.0",
"@aws-sdk/lib-storage": "^3.808.0",
"@aws-sdk/s3-request-presigner": "^3.808.0",
"@aws-sdk/client-cloudwatch-logs": "^3.812.0",
"@aws-sdk/client-elasticache": "^3.812.0",
"@aws-sdk/client-s3": "^3.812.0",
"@aws-sdk/client-secrets-manager": "^3.812.0",
"@aws-sdk/client-ses": "^3.812.0",
"@aws-sdk/credential-provider-node": "^3.812.0",
"@aws-sdk/lib-storage": "^3.812.0",
"@aws-sdk/s3-request-presigner": "^3.812.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
@@ -31,14 +31,14 @@
"aws4": "^1.13.2",
"axios": "^1.8.4",
"better-queue": "^3.8.12",
"bullmq": "^5.52.2",
"bullmq": "^5.53.0",
"chart.js": "^4.4.8",
"cloudinary": "^2.6.1",
"compression": "^1.8.0",
"cookie-parser": "^1.4.7",
"cors": "2.8.5",
"cors": "^2.8.5",
"crisp-status-reporter": "^1.2.2",
"dd-trace": "^5.51.0",
"dd-trace": "^5.52.0",
"dinero.js": "^1.9.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
@@ -73,14 +73,14 @@
"xmlbuilder2": "^3.1.1"
},
"devDependencies": {
"@eslint/js": "^9.26.0",
"eslint": "^9.26.0",
"@eslint/js": "^9.27.0",
"eslint": "^9.27.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"mock-require": "^3.0.3",
"p-limit": "^3.1.0",
"prettier": "^3.5.3",
"supertest": "^7.1.1",
"vitest": "^3.1.3"
"vitest": "^3.1.4"
}
}

View File

@@ -14,10 +14,8 @@ const oauthClient = new OAuthClient({
clientSecret: process.env.QBO_SECRET,
environment: process.env.NODE_ENV === "production" ? "production" : "sandbox",
redirectUri: process.env.QBO_REDIRECT_URI,
logging: true
});
//TODO:AIO Add in QBO callbacks.
const url = InstanceEndpoints();
exports.default = async (req, res) => {

View File

@@ -20,7 +20,6 @@ exports.default = async (req, res) => {
clientSecret: process.env.QBO_SECRET,
environment: process.env.NODE_ENV === "production" ? "production" : "sandbox",
redirectUri: process.env.QBO_REDIRECT_URI,
logging: true
});
try {
@@ -149,6 +148,15 @@ async function QueryVendorRecord(oauthClient, qbo_realmId, req, bill) {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryVendorRecord",
billid: bill.id,
status: result.response?.status,
bodyshopid: bill.job.shopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, result);
return (
result.json &&
@@ -178,6 +186,15 @@ async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) {
},
body: JSON.stringify(Vendor)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertVendorRecord",
billid: bill.id,
status: result.response?.status,
bodyshopid: bill.job.shopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, result);
return result && result.json && result.json.Vendor;
} catch (error) {
@@ -190,7 +207,7 @@ async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) {
}
async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop) {
const { accounts, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req);
const { accounts, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req, bill.job.shopid);
const lines = bill.billlines.map((il) =>
generateBillLine(
@@ -246,11 +263,11 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
.format("YYYY-MM-DD"),
...(!bill.is_credit_memo &&
bill.vendor.due_date && {
DueDate: moment(bill.date)
//.tz(bill.job.bodyshop.timezone)
.add(bill.vendor.due_date, "days")
.format("YYYY-MM-DD")
}),
DueDate: moment(bill.date)
//.tz(bill.job.bodyshop.timezone)
.add(bill.vendor.due_date, "days")
.format("YYYY-MM-DD")
}),
DocNumber: bill.invoice_number,
//...(bill.job.class ? { ClassRef: { Id: classes[bill.job.class] } } : {}),
...(!(
@@ -263,8 +280,8 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
: {}),
...(bodyshop.accountingconfig.qbo_departmentid &&
bodyshop.accountingconfig.qbo_departmentid.trim() !== "" && {
DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid }
}),
DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid }
}),
PrivateNote: `RO ${bill.job.ro_number || ""}`,
Line: lines
};
@@ -280,6 +297,15 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
},
body: JSON.stringify(billQbo)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertBill",
billid: bill.id,
status: result.response?.status,
bodyshopid: bill.job.shopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, result);
return result && result.json && result.json.Bill;
} catch (error) {
@@ -327,8 +353,8 @@ const generateBillLine = (
accountingconfig.qbo && accountingconfig.qbo_usa && region_config.includes("CA_")
? {}
: {
value: taxCodes[findTaxCode(billLine.applicable_taxes, ioSalesTaxCodes)]
},
value: taxCodes[findTaxCode(billLine.applicable_taxes, ioSalesTaxCodes)]
},
AccountRef: {
value: accounts[account.accountname]
}
@@ -342,7 +368,7 @@ const generateBillLine = (
};
};
async function QueryMetaData(oauthClient, qbo_realmId, req) {
async function QueryMetaData(oauthClient, qbo_realmId, req, bodyshopid) {
const accounts = await oauthClient.makeApiCall({
url: urlBuilder(
qbo_realmId,
@@ -354,6 +380,14 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryAccountType",
status: accounts.response?.status,
bodyshopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, accounts);
const taxCodes = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From TaxCode`),
@@ -362,7 +396,14 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryTaxCode",
status: taxCodes.status,
bodyshopid,
email: req.user.email
})
const classes = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From Class`),
method: "POST",
@@ -370,7 +411,14 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryClasses",
status: classes.status,
bodyshopid,
email: req.user.email
})
const taxCodeMapping = {};
taxCodes.json &&

View File

@@ -29,7 +29,6 @@ exports.default = async (req, res) => {
clientSecret: process.env.QBO_SECRET,
environment: process.env.NODE_ENV === "production" ? "production" : "sandbox",
redirectUri: process.env.QBO_REDIRECT_URI,
logging: true
});
try {
//Fetch the API Access Tokens & Set them for the session.
@@ -198,7 +197,8 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef,
req,
payment.job.ro_number,
false,
parentRef
parentRef,
payment.job.shopid
);
if (invoices && invoices.length !== 1) {
@@ -227,20 +227,20 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef,
PaymentRefNum: payment.transactionid,
...(invoices && invoices.length === 1 && invoices[0]
? {
Line: [
{
Amount: Dinero({
amount: Math.round(payment.amount * 100)
}).toFormat(DineroQbFormat),
LinkedTxn: [
{
TxnId: invoices[0].Id,
TxnType: "Invoice"
}
]
}
]
}
Line: [
{
Amount: Dinero({
amount: Math.round(payment.amount * 100)
}).toFormat(DineroQbFormat),
LinkedTxn: [
{
TxnId: invoices[0].Id,
TxnType: "Invoice"
}
]
}
]
}
: {})
};
logger.log("qbo-payments-objectlog", "DEBUG", req.user.email, payment.id, {
@@ -255,6 +255,15 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef,
},
body: JSON.stringify(paymentQbo)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertPayment",
paymentid: payment.id,
status: result.response?.status,
bodyshopid: payment.job.shopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, result);
return result && result.Bill;
} catch (error) {
@@ -266,7 +275,7 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef,
}
}
async function QueryMetaData(oauthClient, qbo_realmId, req, ro_number, isCreditMemo, parentTierRef) {
async function QueryMetaData(oauthClient, qbo_realmId, req, ro_number, isCreditMemo, parentTierRef, bodyshopid) {
const invoice = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From Invoice where DocNumber like '${ro_number}%'`),
method: "POST",
@@ -274,7 +283,15 @@ async function QueryMetaData(oauthClient, qbo_realmId, req, ro_number, isCreditM
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryInvoice",
status: invoice.response?.status,
bodyshopid,
email: req.user.email
})
const paymentMethods = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From PaymentMethod`),
method: "POST",
@@ -282,6 +299,14 @@ async function QueryMetaData(oauthClient, qbo_realmId, req, ro_number, isCreditM
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryPaymentMethod",
status: paymentMethods.response?.status,
bodyshopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, paymentMethods);
// const classes = await oauthClient.makeApiCall({
@@ -325,6 +350,15 @@ async function QueryMetaData(oauthClient, qbo_realmId, req, ro_number, isCreditM
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryTaxCode",
status: taxCodes.response?.status,
bodyshopid,
email: req.user.email
})
const items = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From Item`),
method: "POST",
@@ -332,6 +366,14 @@ async function QueryMetaData(oauthClient, qbo_realmId, req, ro_number, isCreditM
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryItems",
status: items.response?.status,
bodyshopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, items);
const itemMapping = {};
@@ -377,7 +419,8 @@ async function InsertCreditMemo(oauthClient, qbo_realmId, req, payment, parentRe
req,
payment.job.ro_number,
true,
parentRef
parentRef,
payment.job.shopid
);
if (invoices && invoices.length !== 1) {
@@ -406,14 +449,14 @@ async function InsertCreditMemo(oauthClient, qbo_realmId, req, payment, parentRe
TaxCodeRef: {
value:
taxCodes[
findTaxCode(
{
local: false,
federal: false,
state: false
},
payment.job.bodyshop.md_responsibility_centers.sales_tax_codes
)
findTaxCode(
{
local: false,
federal: false,
state: false
},
payment.job.bodyshop.md_responsibility_centers.sales_tax_codes
)
]
}
}
@@ -432,6 +475,15 @@ async function InsertCreditMemo(oauthClient, qbo_realmId, req, payment, parentRe
},
body: JSON.stringify(paymentQbo)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertCreditMemo",
paymentid: payment.id,
status: result.response?.status,
bodyshopid: req.user.bodyshopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, result);
return result && result.Bill;
} catch (error) {

View File

@@ -22,8 +22,8 @@ exports.default = async (req, res) => {
clientSecret: process.env.QBO_SECRET,
environment: process.env.NODE_ENV === "production" ? "production" : "sandbox",
redirectUri: process.env.QBO_REDIRECT_URI,
logging: true
});
try {
//Fetch the API Access Tokens & Set them for the session.
const response = await apiGqlClient.request(queries.GET_QBO_AUTH, {
@@ -233,6 +233,15 @@ async function QueryInsuranceCo(oauthClient, qbo_realmId, req, job) {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryCustomer",
status: result.response?.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result);
return (
result.json &&
@@ -279,6 +288,15 @@ async function InsertInsuranceCo(oauthClient, qbo_realmId, req, job, bodyshop) {
},
body: JSON.stringify(Customer)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertCustomer",
status: result.response.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result);
return result && result.json.Customer;
} catch (error) {
@@ -305,6 +323,15 @@ async function QueryOwner(oauthClient, qbo_realmId, req, job, isThreeTier, paren
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryCustomer",
status: result.response?.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result);
return (
result.json &&
@@ -331,11 +358,11 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
...(job.ownr_ea ? { PrimaryEmailAddr: { Address: job.ownr_ea.trim() } } : {}),
...(isThreeTier
? {
Job: true,
ParentRef: {
value: parentTierRef.Id
}
Job: true,
ParentRef: {
value: parentTierRef.Id
}
}
: {})
};
try {
@@ -347,6 +374,15 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
},
body: JSON.stringify(Customer)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertCustomer",
status: result.response?.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result);
return result && result.json.Customer;
} catch (error) {
@@ -372,6 +408,15 @@ async function QueryJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryCustomer",
status: result.response?.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result);
return (
result.json &&
@@ -411,6 +456,15 @@ async function InsertJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
},
body: JSON.stringify(Customer)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertCustomer",
status: result.response?.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result);
return result && result.json.Customer;
} catch (error) {
@@ -424,7 +478,7 @@ async function InsertJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
exports.InsertJob = InsertJob;
async function QueryMetaData(oauthClient, qbo_realmId, req) {
async function QueryMetaData(oauthClient, qbo_realmId, req, bodyshopid) {
const items = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From Item where active=true maxresults 1000`),
method: "POST",
@@ -432,6 +486,14 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryItems",
status: items.response?.status,
bodyshopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, items);
const taxCodes = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From TaxCode where active=true`),
@@ -440,7 +502,14 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryTaxCodes",
status: taxCodes.response?.status,
bodyshopid,
email: req.user.email
})
const classes = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From Class`),
method: "POST",
@@ -448,7 +517,14 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryClasses",
status: classes.response?.status,
bodyshopid,
email: req.user.email
})
const taxCodeMapping = {};
taxCodes.json &&
@@ -483,7 +559,7 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
}
async function InsertInvoice(oauthClient, qbo_realmId, req, job, bodyshop, parentTierRef) {
const { items, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req);
const { items, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req, job.shopid);
const InvoiceLineAdd = CreateInvoiceLines({
bodyshop,
jobs_by_pk: job,
@@ -499,57 +575,55 @@ async function InsertInvoice(oauthClient, qbo_realmId, req, job, bodyshop, paren
DocNumber: job.ro_number,
...(job.class ? { ClassRef: { value: classes[job.class] } } : {}),
CustomerMemo: {
value: `${job.clm_no ? `Claim No: ${job.clm_no}` : ``}${
job.po_number ? `PO No: ${job.po_number}` : ``
} Vehicle:${job.v_model_yr || ""} ${job.v_make_desc || ""} ${
job.v_model_desc || ""
} ${job.v_vin || ""} ${job.plate_no || ""} `.trim()
value: `${job.clm_no ? `Claim No: ${job.clm_no}` : ``}${job.po_number ? `PO No: ${job.po_number}` : ``
} Vehicle:${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""
} ${job.v_vin || ""} ${job.plate_no || ""} `.trim()
},
CustomerRef: {
value: parentTierRef.Id
},
...(bodyshop.accountingconfig.qbo_departmentid &&
bodyshop.accountingconfig.qbo_departmentid.trim() !== "" && {
DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid }
}),
DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid }
}),
CustomField: [
...(bodyshop.accountingconfig.ReceivableCustomField1
? [
{
DefinitionId: "1",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField1],
Type: "StringType"
}
]
{
DefinitionId: "1",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField1],
Type: "StringType"
}
]
: []),
...(bodyshop.accountingconfig.ReceivableCustomField2
? [
{
DefinitionId: "2",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField2],
Type: "StringType"
}
]
{
DefinitionId: "2",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField2],
Type: "StringType"
}
]
: []),
...(bodyshop.accountingconfig.ReceivableCustomField3
? [
{
DefinitionId: "3",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField3],
Type: "StringType"
}
]
{
DefinitionId: "3",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField3],
Type: "StringType"
}
]
: [])
],
...(bodyshop.accountingconfig &&
bodyshop.accountingconfig.qbo &&
bodyshop.accountingconfig.qbo_usa && {
TxnTaxDetail: {
TxnTaxCodeRef: {
value: taxCodes[bodyshop.md_responsibility_centers.taxes.state.accountitem]
}
TxnTaxDetail: {
TxnTaxCodeRef: {
value: taxCodes[bodyshop.md_responsibility_centers.taxes.state.accountitem]
}
}),
}
}),
...(bodyshop.accountingconfig.printlater ? { PrintStatus: "NeedToPrint" } : {}),
...(bodyshop.accountingconfig.emaillater && job.ownr_ea ? { EmailStatus: "NeedToSend" } : {}),
@@ -575,6 +649,15 @@ async function InsertInvoice(oauthClient, qbo_realmId, req, job, bodyshop, paren
},
body: JSON.stringify(invoiceObj)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertInvoice",
status: result.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result);
return result && result.json && result.json.Invoice;
} catch (error) {
@@ -598,7 +681,7 @@ async function InsertInvoiceMultiPayerInvoice(
payer,
suffix
) {
const { items, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req);
const { items, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req, job.shopid);
const InvoiceLineAdd = createMultiQbPayerLines({
bodyshop,
jobs_by_pk: job,
@@ -616,58 +699,56 @@ async function InsertInvoiceMultiPayerInvoice(
DocNumber: job.ro_number + suffix,
...(job.class ? { ClassRef: { value: classes[job.class] } } : {}),
CustomerMemo: {
value: `${job.clm_no ? `Claim No: ${job.clm_no}` : ``}${
job.po_number ? `PO No: ${job.po_number}` : ``
} Vehicle:${job.v_model_yr || ""} ${job.v_make_desc || ""} ${
job.v_model_desc || ""
} ${job.v_vin || ""} ${job.plate_no || ""} `.trim()
value: `${job.clm_no ? `Claim No: ${job.clm_no}` : ``}${job.po_number ? `PO No: ${job.po_number}` : ``
} Vehicle:${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""
} ${job.v_vin || ""} ${job.plate_no || ""} `.trim()
},
CustomerRef: {
value: parentTierRef.Id
},
...(bodyshop.accountingconfig.qbo_departmentid &&
bodyshop.accountingconfig.qbo_departmentid.trim() !== "" && {
DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid }
}),
DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid }
}),
CustomField: [
...(bodyshop.accountingconfig.ReceivableCustomField1
? [
{
DefinitionId: "1",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField1],
Type: "StringType"
}
]
{
DefinitionId: "1",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField1],
Type: "StringType"
}
]
: []),
...(bodyshop.accountingconfig.ReceivableCustomField2
? [
{
DefinitionId: "2",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField2],
Type: "StringType"
}
]
{
DefinitionId: "2",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField2],
Type: "StringType"
}
]
: []),
...(bodyshop.accountingconfig.ReceivableCustomField3
? [
{
DefinitionId: "3",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField3],
Type: "StringType"
}
]
{
DefinitionId: "3",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField3],
Type: "StringType"
}
]
: [])
],
...(bodyshop.accountingconfig &&
bodyshop.accountingconfig.qbo &&
bodyshop.accountingconfig.qbo_usa &&
bodyshop.region_config.includes("CA_") && {
TxnTaxDetail: {
TxnTaxCodeRef: {
value: taxCodes[bodyshop.md_responsibility_centers.taxes.state.accountitem]
}
TxnTaxDetail: {
TxnTaxCodeRef: {
value: taxCodes[bodyshop.md_responsibility_centers.taxes.state.accountitem]
}
}),
}
}),
...(bodyshop.accountingconfig.printlater ? { PrintStatus: "NeedToPrint" } : {}),
...(bodyshop.accountingconfig.emaillater && job.ownr_ea ? { EmailStatus: "NeedToSend" } : {}),
@@ -693,6 +774,15 @@ async function InsertInvoiceMultiPayerInvoice(
},
body: JSON.stringify(invoiceObj)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertInvoice",
status: result.response.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result);
return result && result.json && result.json.Invoice;
} catch (error) {

View File

@@ -6,7 +6,7 @@ require("dotenv").config({
function urlBuilder(realmId, object, query = null) {
return `https://${
process.env.NODE_ENV === "production" ? "" : "sandbox-"
}quickbooks.api.intuit.com/v3/company/${realmId}/${object}${query ? `?query=${encodeURIComponent(query)}` : ""}`;
}quickbooks.api.intuit.com/v3/company/${realmId}/${object}?minorversion=75${query ? `&query=${encodeURIComponent(query)}` : ""}`;
}
function StandardizeName(str) {

View File

@@ -222,6 +222,7 @@ query QUERY_JOBS_FOR_RECEIVABLES_EXPORT($ids: [uuid!]!) {
rate_mash
rate_matd
class
shopid
ca_bc_pvrt
ca_customer_gst
towing_payable
@@ -480,6 +481,7 @@ query QUERY_BILLS_FOR_PAYABLES_EXPORT($bills: [uuid!]!) {
ownr_ln
ownr_co_nm
class
shopid
}
billlines{
id
@@ -530,6 +532,7 @@ exports.QUERY_PAYMENTS_FOR_EXPORT = `
ownr_fn
ownr_ln
ownr_co_nm
shopid
bodyshop {
accountingconfig
md_responsibility_centers
@@ -1596,6 +1599,7 @@ query QUERY_JOB_COSTING_DETAILS($id: uuid!) {
ca_customer_gst
dms_allocation
cieca_pfl
cieca_stl
materials
joblines(where: { removed: { _eq: false } }) {
id
@@ -1712,6 +1716,7 @@ exports.QUERY_JOB_COSTING_DETAILS_MULTI = ` query QUERY_JOB_COSTING_DETAILS_MULT
ca_customer_gst
dms_allocation
cieca_pfl
cieca_stl
materials
joblines(where: {removed: {_eq: false}}) {
id
@@ -2968,3 +2973,11 @@ exports.GET_JOB_WATCHERS_MINIMAL = `
}
}
`;
exports.INSERT_INTEGRATION_LOG = `
mutation INSERT_INTEGRATION_LOG($log: integration_log_insert_input!) {
insert_integration_log_one(object: $log) {
id
}
}
`;

View File

@@ -567,6 +567,29 @@ function GenerateCostingData(job) {
);
}
if (InstanceManager({ imex: false, rome: true })) {
const stlTowing = job.cieca_stl?.data.find((c) => c.ttl_type === "OTTW");
const stlStorage = job.cieca_stl?.data.find((c) => c.ttl_type === "OTST");
if (!jobLineTotalsByProfitCenter.additional[defaultProfits["TOW"]])
jobLineTotalsByProfitCenter.additional[defaultProfits["TOW"]] = Dinero();
jobLineTotalsByProfitCenter.additional[defaultProfits["TOW"]] = stlTowing
? Dinero({ amount: Math.round(stlTowing.ttl_amt * 100) })
: Dinero({
amount: Math.round((job.towing_payable || 0) * 100)
});
if (!jobLineTotalsByProfitCenter.additional[defaultProfits["STO"]])
jobLineTotalsByProfitCenter.additional[defaultProfits["STO"]] = Dinero();
jobLineTotalsByProfitCenter.additional[defaultProfits["STO"]] = stlStorage
? Dinero({ amount: Math.round(stlStorage.ttl_amt * 100) })
: Dinero({
amount: Math.round((job.storage_payable || 0) * 100)
});
}
//Is it a DMS Setup?
const selectedDmsAllocationConfig =
(job.bodyshop.md_responsibility_centers.dms_defaults &&

View File

@@ -7,7 +7,7 @@ const { status, markConversationRead } = require("../sms/status");
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
// Twilio Webhook Middleware for production
// TODO: Look into this because it technically is never validating anything
// TODO: This is never actually doing anything, we should probably verify
const twilioWebhookMiddleware = twilio.webhook({ validate: process.env.NODE_ENV === "PRODUCTION" });
router.post("/receive", twilioWebhookMiddleware, receive);

View File

@@ -1,17 +1,23 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const client = require("../graphql-client/graphql-client").client;
const queries = require("../graphql-client/queries");
const {
FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID,
UNARCHIVE_CONVERSATION,
CREATE_CONVERSATION,
INSERT_MESSAGE
} = require("../graphql-client/queries");
const { phone } = require("phone");
const { admin } = require("../firebase/firebase-handler");
const logger = require("../utils/logger");
const InstanceManager = require("../utils/instanceMgr").default;
exports.receive = async (req, res) => {
/**
* Receive SMS messages from Twilio and process them
* @param req
* @param res
* @returns {Promise<*>}
*/
const receive = async (req, res) => {
const {
logger,
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
@@ -20,7 +26,7 @@ exports.receive = async (req, res) => {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body)
image_path: generateMediaArray(req.body, logger)
};
logger.log("sms-inbound", "DEBUG", "api", null, loggerData);
@@ -35,7 +41,7 @@ exports.receive = async (req, res) => {
try {
// Step 1: Find the bodyshop and existing conversation
const response = await client.request(queries.FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, {
const response = await client.request(FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, {
mssid: req.body.MessagingServiceSid,
phone: phone(req.body.From).phoneNumber
});
@@ -46,7 +52,7 @@ exports.receive = async (req, res) => {
const bodyshop = response.bodyshops[0];
// Sort conversations by `updated_at` (or `created_at`) and pick the last one
// Step 4: Process conversation
const sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
const existingConversation = sortedConversations.length
? sortedConversations[sortedConversations.length - 1]
@@ -57,25 +63,21 @@ exports.receive = async (req, res) => {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body),
image_path: generateMediaArray(req.body, logger),
isoutbound: false,
userid: null // Add additional fields as necessary
userid: null
};
if (existingConversation) {
// Use the existing conversation
conversationid = existingConversation.id;
// Unarchive the conversation if necessary
if (existingConversation.archived) {
await client.request(queries.UNARCHIVE_CONVERSATION, {
await client.request(UNARCHIVE_CONVERSATION, {
id: conversationid,
archived: false
});
}
} else {
// Create a new conversation
const newConversationResponse = await client.request(queries.CREATE_CONVERSATION, {
const newConversationResponse = await client.request(CREATE_CONVERSATION, {
conversation: {
bodyshopid: bodyshop.id,
phone_num: phone(req.body.From).phoneNumber,
@@ -86,13 +88,12 @@ exports.receive = async (req, res) => {
conversationid = createdConversation.id;
}
// Ensure `conversationid` is added to the message
newMessage.conversationid = conversationid;
// Step 3: Insert the message into the conversation
const insertresp = await client.request(queries.INSERT_MESSAGE, {
// Step 5: Insert the message
const insertresp = await client.request(INSERT_MESSAGE, {
msg: newMessage,
conversationid: conversationid
conversationid
});
const message = insertresp?.insert_messages?.returning?.[0];
@@ -102,8 +103,7 @@ exports.receive = async (req, res) => {
throw new Error("Conversation data is missing from the response.");
}
// Step 4: Notify clients through Redis
const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id);
// Step 6: Notify clients
const conversationRoom = getBodyshopConversationRoom({
bodyshopId: conversation.bodyshop.id,
conversationId: conversation.id
@@ -116,6 +116,8 @@ exports.receive = async (req, res) => {
msid: message.sid
};
const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id);
ioRedis.to(broadcastRoom).emit("new-message-summary", {
...commonPayload,
existingConversation: !!existingConversation,
@@ -131,13 +133,13 @@ exports.receive = async (req, res) => {
summary: false
});
// Step 5: Send FCM notification
// Step 7: Send FCM notification
const fcmresp = await admin.messaging().send({
topic: `${message.conversation.bodyshop.imexshopid}-messaging`,
notification: {
title: InstanceManager({
imex: `ImEX Online Message - ${message.conversation.phone_num}`,
rome: `Rome Online Message - ${message.conversation.phone_num}`,
rome: `Rome Online Message - ${message.conversation.phone_num}`
}),
body: message.image_path ? `Image ${message.text}` : message.text
},
@@ -157,11 +159,17 @@ exports.receive = async (req, res) => {
res.status(200).send("");
} catch (e) {
handleError(req, e, res, "RECEIVE_MESSAGE");
handleError(req, e, res, "RECEIVE_MESSAGE", logger);
}
};
const generateMediaArray = (body) => {
/**
* Generate media array from the request body
* @param body
* @param logger
* @returns {null|*[]}
*/
const generateMediaArray = (body, logger) => {
const { NumMedia } = body;
if (parseInt(NumMedia) > 0) {
const ret = [];
@@ -174,12 +182,20 @@ const generateMediaArray = (body) => {
}
};
const handleError = (req, error, res, context) => {
/**
* Handle error logging and response
* @param req
* @param error
* @param res
* @param context
* @param logger
*/
const handleError = (req, error, res, context, logger) => {
logger.log("sms-inbound-error", "ERROR", "api", null, {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body),
image_path: generateMediaArray(req.body, logger),
messagingServiceSid: req.body.MessagingServiceSid,
context,
error
@@ -187,3 +203,7 @@ const handleError = (req, error, res, context) => {
res.status(500).json({ error: error.message || "Internal Server Error" });
};
module.exports = {
receive
};

View File

@@ -1,19 +1,20 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const twilio = require("twilio");
const { phone } = require("phone");
const queries = require("../graphql-client/queries");
const logger = require("../utils/logger");
const { INSERT_MESSAGE } = require("../graphql-client/queries");
const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY);
const gqlClient = require("../graphql-client/graphql-client").client;
exports.send = async (req, res) => {
/**
* Send an outbound SMS message
* @param req
* @param res
* @returns {Promise<void>}
*/
const send = async (req, res) => {
const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body;
const {
ioRedis,
logger,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
@@ -25,8 +26,8 @@ exports.send = async (req, res) => {
conversationid,
isoutbound: true,
userid: req.user.email,
image: req.body.selectedMedia.length > 0,
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
image: selectedMedia.length > 0,
image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
});
if (!to || !messagingServiceSid || (!body && selectedMedia.length === 0) || !conversationid) {
@@ -38,8 +39,8 @@ exports.send = async (req, res) => {
conversationid,
isoutbound: true,
userid: req.user.email,
image: req.body.selectedMedia.length > 0,
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
image: selectedMedia.length > 0,
image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
});
res.status(400).json({ success: false, message: "Missing required parameter(s)." });
return;
@@ -59,12 +60,15 @@ exports.send = async (req, res) => {
conversationid,
isoutbound: true,
userid: req.user.email,
image: req.body.selectedMedia.length > 0,
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
image: selectedMedia.length > 0,
image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
};
try {
const gqlResponse = await gqlClient.request(queries.INSERT_MESSAGE, { msg: newMessage, conversationid });
const gqlResponse = await gqlClient.request(INSERT_MESSAGE, {
msg: newMessage,
conversationid
});
logger.log("sms-outbound-success", "DEBUG", req.user.email, null, {
msid: message.sid,
@@ -111,3 +115,7 @@ exports.send = async (req, res) => {
res.status(500).json({ success: false, message: "Failed to send message through Twilio." });
}
};
module.exports = {
send
};

View File

@@ -1,13 +1,14 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const client = require("../graphql-client/graphql-client").client;
const queries = require("../graphql-client/queries");
const { UPDATE_MESSAGE_STATUS, MARK_MESSAGES_AS_READ } = require("../graphql-client/queries");
const logger = require("../utils/logger");
exports.status = async (req, res) => {
/**
* Handle the status of an SMS message
* @param req
* @param res
* @returns {Promise<*>}
*/
const status = async (req, res) => {
const { SmsSid, SmsStatus } = req.body;
const {
ioRedis,
@@ -21,7 +22,7 @@ exports.status = async (req, res) => {
}
// Update message status in the database
const response = await client.request(queries.UPDATE_MESSAGE_STATUS, {
const response = await client.request(UPDATE_MESSAGE_STATUS, {
msid: SmsSid,
fields: { status: SmsStatus }
});
@@ -65,7 +66,13 @@ exports.status = async (req, res) => {
}
};
exports.markConversationRead = async (req, res) => {
/**
* Mark a conversation as read
* @param req
* @param res
* @returns {Promise<*>}
*/
const markConversationRead = async (req, res) => {
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
@@ -80,7 +87,7 @@ exports.markConversationRead = async (req, res) => {
}
try {
const response = await client.request(queries.MARK_MESSAGES_AS_READ, {
const response = await client.request(MARK_MESSAGES_AS_READ, {
conversationId
});
@@ -104,3 +111,8 @@ exports.markConversationRead = async (req, res) => {
res.status(500).json({ error: "Failed to mark conversation as read." });
}
};
module.exports = {
status,
markConversationRead
};

View File

@@ -1,3 +1,11 @@
/**
* @module ioHelpers
* @param app
* @param api
* @param io
* @param logger
* @returns {{getBodyshopRoom: (function(*): string), getBodyshopConversationRoom: (function({bodyshopId: *, conversationId: *}): string)}}
*/
const applyIOHelpers = ({ app, api, io, logger }) => {
// Global Bodyshop Room
const getBodyshopRoom = (bodyshopId) => `bodyshop-broadcast-room:${bodyshopId}`;

View File

@@ -12,6 +12,9 @@ const { uploadFileToS3 } = require("./s3");
const { v4 } = require("uuid");
const { InstanceRegion } = require("./instanceMgr");
const getHostNameOrIP = require("./getHostNameOrIP");
const client = require("../graphql-client/graphql-client").client;
const queries = require("../graphql-client/queries");
const LOG_LEVELS = {
error: { level: 0, name: "error" },
@@ -99,13 +102,11 @@ const createLogger = () => {
const labelColor = "\x1b[33m"; // Yellow
const separatorColor = "\x1b[35m|\x1b[0m"; // Magenta for separators
return `${timestampColor} [${hostnameColor}] [${level}]: ${message} ${
user ? `${separatorColor} ${labelColor}user:\x1b[0m ${JSON.stringify(user)}` : ""
} ${record ? `${separatorColor} ${labelColor}record:\x1b[0m ${JSON.stringify(record)}` : ""}${
meta
return `${timestampColor} [${hostnameColor}] [${level}]: ${message} ${user ? `${separatorColor} ${labelColor}user:\x1b[0m ${JSON.stringify(user)}` : ""
} ${record ? `${separatorColor} ${labelColor}record:\x1b[0m ${JSON.stringify(record)}` : ""}${meta
? `\n${separatorColor} ${labelColor}meta:\x1b[0m ${JSON.stringify(meta, null, 2)} ${separatorColor}`
: ""
}`;
}`;
})
)
})
@@ -194,9 +195,45 @@ const createLogger = () => {
winstonLogger.log(logEntry);
};
const LogIntegrationCall = async ({ platform, method, name, jobid, paymentid, billid, status, bodyshopid, email }) => {
try {
//Insert the record.
await client.request(queries.INSERT_INTEGRATION_LOG, {
log: {
platform,
method,
name,
jobid,
paymentid,
billid,
status: status.toString() ?? "0",
bodyshopid,
email
}
});
} catch (error) {
console.trace("Stack", error?.stack);
log("integration-log-error", "ERROR", email, null, {
message: error?.message,
stack: error?.stack,
platform,
method,
name,
jobid,
paymentid,
billid,
status,
bodyshopid,
email
});
}
};
return {
log,
logger: winstonLogger
logger: winstonLogger,
LogIntegrationCall
};
} catch (e) {
console.error("Error setting up enhanced Logger, defaulting to console.: " + e?.message || "");