Merged in release/2025-06-02 (pull request #2364)

Release 2025-06-02 into master-AIO - IO-3075, IO-3105, IO-3182, IO-3214, IO-3230, IO-3235, IO-3236, IO-3239, IO-3243, IO-3246, IO-3247, IO-3249, IO-3251
This commit is contained in:
Dave Richer
2025-06-02 23:58:37 +00:00
90 changed files with 2739 additions and 1816 deletions

View File

@@ -12791,27 +12791,6 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>allow_text_message</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>checklist</name>
<definition_loaded>false</definition_loaded>
@@ -42614,27 +42593,6 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>allow_text_message</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>name</name>
<definition_loaded>false</definition_loaded>

481
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.6",
"@firebase/firestore": "^4.7.16",
"@firebase/messaging": "^0.12.21",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.8.1",
"@sentry/cli": "^2.45.0",
"@sentry/react": "^9.18.0",
"@sentry/vite-plugin": "^3.4.0",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.46.0",
"@sentry/react": "^9.23.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.3",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.3.0",
"autosize": "^6.0.1",
@@ -48,8 +48,9 @@
"memoize-one": "^6.0.0",
"normalize-url": "^8.0.1",
"object-hash": "^3.0.0",
"phone": "^3.1.59",
"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",
@@ -59,7 +60,7 @@
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "1.3.4",
"react-i18next": "^15.4.1",
"react-i18next": "^15.5.2",
"react-icons": "^5.5.0",
"react-image-lightbox": "^5.1.4",
"react-markdown": "^10.1.0",
@@ -78,7 +79,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,16 +91,16 @@
"@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",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^4.5.0",
"browserslist": "^4.24.5",
"browserslist-to-esbuild": "^2.1.1",
"chalk": "^5.4.1",
@@ -108,7 +109,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 +121,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 +2588,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 +2913,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 +2935,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 +2951,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 +2967,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.6",
"resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.6.tgz",
"integrity": "sha512-cFbo2FymQltog4atI9cKTO6CxKxS0dOMXslTQrlNZRH7qhDG44/d7QeI6GXLweFZtrnlecf52ESnNz1DU6ek8w==",
"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 +2991,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 +3004,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.16",
"resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.7.16.tgz",
"integrity": "sha512-5OpvlwYVUTLEnqewOlXmtIpH8t2ISlZHDW0NDbKROM2D0ATMqFkMHdvl+/wz9zOAcb8GMQYlhCihOnVAliUbpQ==",
"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 +3025,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 +3052,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 +3075,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 +3840,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",
@@ -3882,6 +3886,13 @@
"react": ">=16.8.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.9",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz",
"integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/plugin-babel": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -4458,88 +4469,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.23.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.23.0.tgz",
"integrity": "sha512-hyN2Q6mh7ggw8sDVHeRyWz5LR6gjvf8zHSzQnMaF7QkeSyaeGM/SVSL4ODwqR9TRH7U2ku6nZFMbKhaBPV+Hfg==",
"license": "MIT",
"dependencies": {
"@sentry/core": "9.18.0"
"@sentry/core": "9.23.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.23.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.23.0.tgz",
"integrity": "sha512-Xf+KqV69TBiPo1gk2EsU6O/dumuTMxWOF52uVWJddQYI3pQTU5DqSeoZ5AY76bIIhV9n6AEFDGqNPXmuj4Acrw==",
"license": "MIT",
"dependencies": {
"@sentry/core": "9.18.0"
"@sentry/core": "9.23.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.23.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.23.0.tgz",
"integrity": "sha512-0/q15tvSboaK7/05BFQhs71bqgHKejJoDJgXmH0lySqgsRh/S18867ZxQNiuYhuVt337h07u1QaCyjnNJKHfuA==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "9.18.0",
"@sentry/core": "9.18.0"
"@sentry-internal/browser-utils": "9.23.0",
"@sentry/core": "9.23.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.23.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.23.0.tgz",
"integrity": "sha512-cYlw5svJjyPequm0PJjFGLpee86L1NieONEHlujOXkIG6IEriiorMm+8bNpGsHRuyvg41B+4P/YmcQAGtEGxXg==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "9.18.0",
"@sentry/core": "9.18.0"
"@sentry-internal/replay": "9.23.0",
"@sentry/core": "9.23.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.23.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.23.0.tgz",
"integrity": "sha512-QRkNxWys8e088260vByztoTsEVZ0W6v/XnZfKT6wkPPGn0aFeOrg/xjgxfI8D5huqZCxT28Cf23akOOly4FXjg==",
"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.23.0",
"@sentry-internal/feedback": "9.23.0",
"@sentry-internal/replay": "9.23.0",
"@sentry-internal/replay-canvas": "9.23.0",
"@sentry/core": "9.23.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",
@@ -4717,9 +4728,9 @@
}
},
"node_modules/@sentry/cli": {
"version": "2.45.0",
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.45.0.tgz",
"integrity": "sha512-4sWu7zgzgHAjIxIjXUA/66qgeEf5ZOlloO+/JaGD5qXNSW0G7KMTR6iYjReNKMgdBCTH6bUUt9qiuA+Ex9Masw==",
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.46.0.tgz",
"integrity": "sha512-nqoPl7UCr446QFkylrsRrUXF51x8Z9dGquyf4jaQU+OzbOJMqclnYEvU6iwbwvaw3tu/2DnoZE/Og+Nq1h63sA==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -4736,20 +4747,20 @@
"node": ">= 10"
},
"optionalDependencies": {
"@sentry/cli-darwin": "2.45.0",
"@sentry/cli-linux-arm": "2.45.0",
"@sentry/cli-linux-arm64": "2.45.0",
"@sentry/cli-linux-i686": "2.45.0",
"@sentry/cli-linux-x64": "2.45.0",
"@sentry/cli-win32-arm64": "2.45.0",
"@sentry/cli-win32-i686": "2.45.0",
"@sentry/cli-win32-x64": "2.45.0"
"@sentry/cli-darwin": "2.46.0",
"@sentry/cli-linux-arm": "2.46.0",
"@sentry/cli-linux-arm64": "2.46.0",
"@sentry/cli-linux-i686": "2.46.0",
"@sentry/cli-linux-x64": "2.46.0",
"@sentry/cli-win32-arm64": "2.46.0",
"@sentry/cli-win32-i686": "2.46.0",
"@sentry/cli-win32-x64": "2.46.0"
}
},
"node_modules/@sentry/cli-darwin": {
"version": "2.45.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.45.0.tgz",
"integrity": "sha512-p4Uxfv/L2fQdP3/wYnKVVz9gzZJf/1Xp9D+6raax/3Bu5y87yHYUqcdt98y/VAXQD4ofp2QgmhGUVPofvQNZmg==",
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.46.0.tgz",
"integrity": "sha512-5Ll+e5KAdIk9OYiZO8aifMBRNWmNyPjSqdjaHlBC1Qfh7pE3b1zyzoHlsUazG0bv0sNrSGea8e7kF5wIO1hvyg==",
"license": "BSD-3-Clause",
"optional": true,
"os": [
@@ -4760,9 +4771,9 @@
}
},
"node_modules/@sentry/cli-linux-arm": {
"version": "2.45.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.45.0.tgz",
"integrity": "sha512-6sEskFLlFKJ+e0MOYgIclBTUX5jYMyYhHIxXahEkI/4vx6JO0uvpyRAkUJRpJkRh/lPog0FM+tbP3so+VxB2qQ==",
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.46.0.tgz",
"integrity": "sha512-WRrLNq/TEX/TNJkGqq6Ad0tGyapd5dwlxtsPbVBrIdryuL1mA7VCBoaHBr3kcwJLsgBHFH0lmkMee2ubNZZdkg==",
"cpu": [
"arm"
],
@@ -4770,16 +4781,17 @@
"optional": true,
"os": [
"linux",
"freebsd"
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-arm64": {
"version": "2.45.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.45.0.tgz",
"integrity": "sha512-gUcLoEjzg7AIc4QQGEZwRHri+EHf3Gcms9zAR1VHiNF3/C/jL4WeDPJF2YiWAQt6EtH84tHiyhw1Ab/R8XFClg==",
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.46.0.tgz",
"integrity": "sha512-OEJN8yAjI9y5B4telyqzu27Hi3+S4T8VxZCqJz1+z2Mp0Q/MZ622AahVPpcrVq/5bxrnlZR16+lKh8L1QwNFPg==",
"cpu": [
"arm64"
],
@@ -4787,16 +4799,17 @@
"optional": true,
"os": [
"linux",
"freebsd"
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-i686": {
"version": "2.45.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.45.0.tgz",
"integrity": "sha512-VmmOaEAzSW23YdGNdy/+oQjCNAMY+HmOGA77A25/ep/9AV7PQB6FI7xO5Y1PVvlkxZFJ23e373njSsEeg4uDZw==",
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.46.0.tgz",
"integrity": "sha512-xko3/BVa4LX8EmRxVOCipV+PwfcK5Xs8lP6lgF+7NeuAHMNL4DqF6iV9rrN8gkGUHCUI9RXSve37uuZnFy55+Q==",
"cpu": [
"x86",
"ia32"
@@ -4805,16 +4818,17 @@
"optional": true,
"os": [
"linux",
"freebsd"
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-x64": {
"version": "2.45.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.45.0.tgz",
"integrity": "sha512-a0Oj68mrb25a0WjX/ShZ6AAd4PPiuLcgyzQr7bl2+DvYxIOajwkGbR+CZFEhOVZcfhTnixKy/qIXEzApEPHPQg==",
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.46.0.tgz",
"integrity": "sha512-hJ1g5UEboYcOuRia96LxjJ0jhnmk8EWLDvlGnXLnYHkwy3ree/L7sNgdp/QsY8Z4j2PGO5f22Va+UDhSjhzlfQ==",
"cpu": [
"x64"
],
@@ -4822,16 +4836,17 @@
"optional": true,
"os": [
"linux",
"freebsd"
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-win32-arm64": {
"version": "2.45.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.45.0.tgz",
"integrity": "sha512-vn+CwS4p+52pQSLNPoi20ZOrQmv01ZgAmuMnjkh1oUZfTyBAwWLrAh6Cy4cztcN8DfL5dOWKQBo8DBKURE4ttg==",
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.46.0.tgz",
"integrity": "sha512-mN7cpPoCv2VExFRGHt+IoK11yx4pM4ADZQGEso5BAUZ5duViXB2WrAXCLd8DrwMnP0OE978a7N8OtzsFqjkbNA==",
"cpu": [
"arm64"
],
@@ -4845,9 +4860,9 @@
}
},
"node_modules/@sentry/cli-win32-i686": {
"version": "2.45.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.45.0.tgz",
"integrity": "sha512-8mMoDdlwxtcdNIMtteMK7dbi7054jak8wKSHJ5yzMw8UmWxC5thc/gXBc1uPduiaI56VjoJV+phWHBKCD+6I4w==",
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.46.0.tgz",
"integrity": "sha512-6F73AUE3lm71BISUO19OmlnkFD5WVe4/wA1YivtLZTc1RU3eUYJLYxhDfaH3P77+ycDppQ2yCgemLRaA4A8mNQ==",
"cpu": [
"x86",
"ia32"
@@ -4862,9 +4877,9 @@
}
},
"node_modules/@sentry/cli-win32-x64": {
"version": "2.45.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.45.0.tgz",
"integrity": "sha512-ZvK9cIqFaq7vZ0jkHJ/xh5au6902Dr+AUxSk6L6vCL7JCe2p93KGL/4d8VFB5PD/P7Y9b+105G/e0QIFKzpeOw==",
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.46.0.tgz",
"integrity": "sha512-yuGVcfepnNL84LGA0GjHzdMIcOzMe0bjPhq/rwPsPN+zu11N+nPR2wV2Bum4U0eQdqYH3iAlMdL5/BEQfuLJww==",
"cpu": [
"x64"
],
@@ -4899,22 +4914,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.23.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.23.0.tgz",
"integrity": "sha512-9846pn/BvASGgl7WsnKY4xry98WreP9ToeLfCQTQOf+pNfD/qNPn1/0xPInGni3LVMAXRtfHHMPm2Ghz255N7A==",
"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.23.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.23.0.tgz",
"integrity": "sha512-2J/oOx8jd7Jr2koYIe5IcJyStHBXpjkQnxawo54Zyyvzc96MftyM2Dv5TeYdz7fChU1NIXw7BVbEpkQ9XEQlqg==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "9.18.0",
"@sentry/core": "9.18.0",
"@sentry/browser": "9.23.0",
"@sentry/core": "9.23.0",
"hoist-non-react-statics": "^3.3.2"
},
"engines": {
@@ -4925,12 +4940,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 +4953,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"
},
@@ -5790,15 +5805,16 @@
"license": "ISC"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz",
"integrity": "sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz",
"integrity": "sha512-JuLWaEqypaJmOJPLWwO335Ig6jSgC1FTONCWAxnqcQthLTK/Yc9aH6hr9z/87xciejbQcnP3GnA1FWUSWeXaeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.26.10",
"@babel/plugin-transform-react-jsx-self": "^7.25.9",
"@babel/plugin-transform-react-jsx-source": "^7.25.9",
"@rolldown/pluginutils": "1.0.0-beta.9",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.17.0"
},
@@ -5810,14 +5826,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 +5842,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 +5889,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 +5902,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 +5923,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 +5955,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 +5968,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,12 +6104,12 @@
}
},
"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.3",
"resolved": "https://registry.npmjs.org/antd/-/antd-5.25.3.tgz",
"integrity": "sha512-tBBcAFRjmWM3sitxrL/FEbQL+MTQntYY5bGa5c1ZZZHXWCynkhS3Ch/gy25mGMUY1M/9Uw3pH029v/RGht1x3w==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^7.2.0",
"@ant-design/colors": "^7.2.1",
"@ant-design/cssinjs": "^1.23.0",
"@ant-design/cssinjs-utils": "^1.1.3",
"@ant-design/fast-color": "^2.0.6",
@@ -6128,11 +6144,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",
@@ -6153,9 +6169,9 @@
}
},
"node_modules/antd/node_modules/@ant-design/colors": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.0.tgz",
"integrity": "sha512-bjTObSnZ9C/O8MB/B4OUtd/q9COomuJAR2SYfhxLyHvCKn4EKwCN3e+fWGMo7H5InAyV0wL17jdE9ALrdOW/6A==",
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz",
"integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==",
"license": "MIT",
"dependencies": {
"@ant-design/fast-color": "^2.0.6"
@@ -11833,9 +11849,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": {
@@ -13263,6 +13279,15 @@
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT"
},
"node_modules/phone": {
"version": "3.1.59",
"resolved": "https://registry.npmjs.org/phone/-/phone-3.1.59.tgz",
"integrity": "sha512-CUv22jw0Zgrb/h7v3sEd262zJXS/66h7zyCCRIynx+2FswAJuuFsXsJkIxMUT4UcosKxDx1bJwdZeGnDELLsCw==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -13569,9 +13594,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 +14049,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 +14122,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",
@@ -14400,9 +14425,9 @@
}
},
"node_modules/react-i18next": {
"version": "15.5.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.1.tgz",
"integrity": "sha512-C8RZ7N7H0L+flitiX6ASjq9p5puVJU1Z8VyL3OgM/QOMRf40BMZX+5TkpxzZVcTmOLPX5zlti4InEX5pFyiVeA==",
"version": "15.5.2",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.2.tgz",
"integrity": "sha512-ePODyXgmZQAOYTbZXQn5rRsSBu3Gszo69jxW6aKmlSgxKAI1fOhDwSu6bT4EKHciWPKQ7v7lPrjeiadR6Gi+1A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.0",
@@ -15375,9 +15400,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 +17558,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 +17756,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 +17781,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 +17797,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.6",
"@firebase/firestore": "^4.7.16",
"@firebase/messaging": "^0.12.21",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.8.1",
"@sentry/cli": "^2.45.0",
"@sentry/react": "^9.18.0",
"@sentry/vite-plugin": "^3.4.0",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.46.0",
"@sentry/react": "^9.23.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.3",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.3.0",
"autosize": "^6.0.1",
@@ -47,8 +47,9 @@
"memoize-one": "^6.0.0",
"normalize-url": "^8.0.1",
"object-hash": "^3.0.0",
"phone": "^3.1.59",
"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",
@@ -58,7 +59,7 @@
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "1.3.4",
"react-i18next": "^15.4.1",
"react-i18next": "^15.5.2",
"react-icons": "^5.5.0",
"react-image-lightbox": "^5.1.4",
"react-markdown": "^10.1.0",
@@ -77,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",
@@ -130,16 +131,16 @@
"@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",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^4.5.0",
"browserslist": "^4.24.5",
"browserslist-to-esbuild": "^2.1.1",
"chalk": "^5.4.1",
@@ -148,7 +149,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 +161,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

@@ -3,6 +3,7 @@ import { Button, Form, InputNumber, Popover, Space } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
export default function CABCpvrtCalculator({ disabled, form }) {
const [visibility, setVisibility] = useState(false);
@@ -39,7 +40,7 @@ export default function CABCpvrtCalculator({ disabled, form }) {
);
return (
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}>
<Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
<Button disabled={disabled} onClick={() => setVisibility(true)}>
<CalculatorFilled />
</Button>

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

@@ -202,8 +202,6 @@ export const registerMessagingHandlers = ({ socket, client }) => {
text: message.text
};
// Add cases for other known message types as needed
default:
// Log a warning for unhandled message types
logLocal("handleMessageChanged - Unhandled message type", { type: message.type });
@@ -211,7 +209,7 @@ export const registerMessagingHandlers = ({ socket, client }) => {
}
}
return messageRef; // Keep other messages unchanged
return messageRef;
});
}
}
@@ -245,11 +243,8 @@ export const registerMessagingHandlers = ({ socket, client }) => {
});
const updatedList = existingList?.conversations
? [
newConversation,
...existingList.conversations.filter((conv) => conv.id !== newConversation.id) // Prevent duplicates
]
: [newConversation];
? [newConversation, ...existingList.conversations.filter((conv) => conv.id !== newConversation.id)]
: [newConversation]; // Prevent duplicates
client.cache.writeQuery({
query: CONVERSATION_LIST_QUERY,
@@ -403,6 +398,7 @@ export const registerMessagingHandlers = ({ socket, client }) => {
}
break;
default:
logLocal("handleConversationChanged - Unhandled type", { type });
client.cache.modify({
@@ -419,10 +415,95 @@ export const registerMessagingHandlers = ({ socket, client }) => {
}
};
// Existing handler for phone number opt-out
const handlePhoneNumberOptedOut = async (data) => {
const { bodyshopid, phone_number } = data;
logLocal("handlePhoneNumberOptedOut - Start", data);
try {
client.cache.modify({
id: "ROOT_QUERY",
fields: {
phone_number_opt_out(existing = [], { readField }) {
const phoneNumberExists = existing.some(
(ref) => readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid
);
if (phoneNumberExists) {
logLocal("handlePhoneNumberOptedOut - Phone number already in cache", { phone_number, bodyshopid });
return existing;
}
const newOptOut = {
__typename: "phone_number_opt_out",
id: `temporary-${phone_number}-${Date.now()}`,
bodyshopid,
phone_number,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
return [...existing, newOptOut];
}
},
broadcast: true
});
client.cache.evict({
id: "ROOT_QUERY",
fieldName: "phone_number_opt_out",
args: { bodyshopid, search: phone_number }
});
client.cache.gc();
logLocal("handlePhoneNumberOptedOut - Cache updated successfully", data);
} catch (error) {
console.error("Error updating cache for phone number opt-out:", error);
logLocal("handlePhoneNumberOptedOut - Error", { error: error.message });
}
};
// New handler for phone number opt-in
const handlePhoneNumberOptedIn = async (data) => {
const { bodyshopid, phone_number } = data;
logLocal("handlePhoneNumberOptedIn - Start", data);
try {
// Update the Apollo cache for GET_PHONE_NUMBER_OPT_OUTS by removing the phone number
client.cache.modify({
id: "ROOT_QUERY",
fields: {
phone_number_opt_out(existing = [], { readField }) {
// Filter out the phone number from the opt-out list
return existing.filter(
(ref) => !(readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid)
);
}
},
broadcast: true // Trigger UI updates
});
// Evict the cache entry to force a refetch on next query
client.cache.evict({
id: "ROOT_QUERY",
fieldName: "phone_number_opt_out",
args: { bodyshopid, search: phone_number }
});
client.cache.gc();
logLocal("handlePhoneNumberOptedIn - Cache updated successfully", data);
} catch (error) {
console.error("Error updating cache for phone number opt-in:", error);
logLocal("handlePhoneNumberOptedIn - Error", { error: error.message });
}
};
socket.on("new-message-summary", handleNewMessageSummary);
socket.on("new-message-detailed", handleNewMessageDetailed);
socket.on("message-changed", handleMessageChanged);
socket.on("conversation-changed", handleConversationChanged);
socket.on("phone-number-opted-out", handlePhoneNumberOptedOut);
socket.on("phone-number-opted-in", handlePhoneNumberOptedIn);
};
export const unregisterMessagingHandlers = ({ socket }) => {
@@ -431,4 +512,6 @@ export const unregisterMessagingHandlers = ({ socket }) => {
socket.off("new-message-detailed");
socket.off("message-changed");
socket.off("conversation-changed");
socket.off("phone-number-opted-out");
socket.off("phone-number-opted-in");
};

View File

@@ -1,5 +1,5 @@
import { Badge, Card, List, Space, Tag } from "antd";
import React, { useEffect, useState } from "react";
import { Badge, Card, List, Space, Tag, Tooltip } from "antd";
import { useEffect, useMemo, useState } from "react";
import { connect } from "react-redux";
import { Virtuoso } from "react-virtuoso";
import { createStructuredSelector } from "reselect";
@@ -9,36 +9,62 @@ import { TimeAgoFormatter } from "../../utils/DateFormatter";
import PhoneFormatter from "../../utils/PhoneFormatter";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import _ from "lodash";
import { ExclamationCircleOutlined } from "@ant-design/icons";
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 +86,18 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
</>
);
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count} />;
const cardExtra = (
<>
<Badge count={item.messages_aggregate.aggregate.count} />
{hasOptOutEntry && (
<Tooltip title={t("consent.text_body")}>
<Tag color="red" icon={<ExclamationCircleOutlined />}>
{t("messaging.labels.no_consent")}
</Tag>
</Tooltip>
)}
</>
);
const getCardStyle = () =>
item.id === selectedConversation
@@ -73,9 +110,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 +138,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

@@ -58,6 +58,7 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
userid
created_at
read
is_system
}
`,
data: message

View File

@@ -13,13 +13,14 @@ import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-document
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
import JobDocumentsLocalGalleryExternal from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import "./chat-media-selector.styles.scss";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
const mapDispatchToProps = (dispatch) => ({});
export default connect(mapStateToProps, mapDispatchToProps)(ChatMediaSelector);
export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, conversation }) {
@@ -37,9 +38,8 @@ 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]?.jobid
},
skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0
});
@@ -56,25 +56,25 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
//If Imageproxy is on, rely only on the LMS selector
//If not on, use the old methods.
const content = (
<div>
<div className="media-selector-content">
{loading && <LoadingSpinner />}
{error && <AlertComponent message={error.message} type="error" />}
{selectedMedia.filter((s) => s.isSelected).length >= 10 ? (
<div style={{ color: "red" }}>{t("messaging.labels.maxtenimages")}</div>
<div className="error-message">{t("messaging.labels.maxtenimages")}</div>
) : null}
{Imgproxy.treatment === "on" ? (
<>
{!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]?.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]?.jobid}
/>
)}
</>
@@ -100,12 +100,17 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
return (
<Popover
content={
conversation.job_conversations.length === 0 ? <div>{t("messaging.errors.noattachedjobs")}</div> : content
conversation.job_conversations.length === 0 ? (
<div className="no-jobs-message">{t("messaging.errors.noattachedjobs")}</div>
) : (
content
)
}
title={t("messaging.labels.selectmedia")}
trigger="click"
open={open}
onOpenChange={handleVisibleChange}
overlayClassName="media-selector-popover"
>
<Badge count={selectedMedia.filter((s) => s.isSelected).length}>
<PictureFilled style={{ margin: "0 .5rem" }} />

View File

@@ -0,0 +1,52 @@
.media-selector-popover {
.ant-popover-inner-content {
max-width: 640px;
max-height: 480px;
overflow-y: auto;
padding: 8px;
background-color: #fff;
border-radius: 8px;
}
}
.media-selector-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.error-message {
color: red;
font-size: 12px;
text-align: center;
margin-bottom: 8px;
}
.no-jobs-message {
font-size: 14px;
color: #888;
text-align: center;
padding: 8px;
}
/* Style images within gallery components */
.media-selector-content img {
object-fit: cover;
border-radius: 4px;
margin: 4px;
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: scale(1.05);
}
}
/* Grid layout for gallery components */
.media-selector-content .ant-image, /* Assuming gallery components use Ant Design's Image */
.media-selector-content .gallery-container { /* Fallback for custom gallery classes */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 4px;
}

View File

@@ -4,13 +4,16 @@
height: 100%;
width: 100%;
}
.archive-button {
height: 20px;
border-radius: 4px;
}
.chat-title {
margin-bottom: 5px;
}
.messages {
display: flex;
flex-direction: column;
@@ -37,11 +40,13 @@
gap: 8px;
}
}
.chat-send-message-button{
.chat-send-message-button {
margin: 0.3rem;
padding-left: 0.5rem;
}
.message-icon {
position: absolute;
bottom: 0.1rem;
@@ -125,6 +130,37 @@
}
}
.system {
align-items: center;
margin: 0.5rem 10%;
.message {
background-color: #f5f5f5;
border-radius: 10px;
padding: 0.5rem 1rem;
text-align: center;
font-style: italic;
color: #555;
width: fit-content;
max-width: 80%;
}
.system-label {
font-size: 0.75rem;
color: #888;
margin-bottom: 0.2rem;
display: block;
}
.system-date {
font-size: 0.75rem;
color: #888;
margin-top: 0.2rem;
text-align: center;
}
}
.virtuoso-container {
flex: 1;
overflow: auto;

View File

@@ -2,17 +2,29 @@ import Icon from "@ant-design/icons";
import { Tooltip } from "antd";
import i18n from "i18next";
import dayjs from "../../utils/day";
import { MdDone, MdDoneAll } from "react-icons/md";
import { MdClose, MdDone, MdDoneAll } from "react-icons/md";
import { DateTimeFormatter } from "../../utils/DateFormatter";
export const renderMessage = (messages, index) => {
const message = messages[index];
const isSystem = message.is_system;
// Determine message class
const messageClass = isSystem ? "system messages" : message.isoutbound ? "mine messages" : "yours messages";
// Tooltip content based on message type
const tooltipTitle = isSystem ? (
i18n.t("consent.text_body")
) : (
<DateTimeFormatter>{message.created_at}</DateTimeFormatter>
);
return (
<div key={index} className={`${message.isoutbound ? "mine messages" : "yours messages"}`}>
<div key={index} className={messageClass}>
<div className="message msgmargin">
<Tooltip title={DateTimeFormatter({ children: message.created_at })}>
<Tooltip title={tooltipTitle}>
<div>
{isSystem && <span className="system-label">System</span>}
{/* Render images if available */}
{message.image && message.image_path?.length > 0 && (
<div className="message-images">
@@ -26,20 +38,31 @@ export const renderMessage = (messages, index) => {
</div>
)}
{/* Render text if available */}
{message.text && <div>{message.text}</div>}
{message.text && <div className="message-text">{message.text}</div>}
{/* Render date for system messages */}
{isSystem && (
<div className="system-date">
<DateTimeFormatter>{message.created_at}</DateTimeFormatter>
</div>
)}
</div>
</Tooltip>
{/* Message status icons */}
{message.status && (message.status === "sent" || message.status === "delivered") && (
<div className="message-status">
<Icon component={message.status === "sent" ? MdDone : MdDoneAll} className="message-icon" />
</div>
)}
{/* Message status icons for non-system messages */}
{!isSystem &&
message.status &&
(message.status === "sent" || message.status === "delivered" || message.status === "failed") && (
<div className="message-status">
<Icon
component={message.status === "sent" ? MdDone : message.status === "delivered" ? MdDoneAll : MdClose}
className="message-icon"
style={message.status === "failed" ? { color: "#ff0000" } : undefined}
/>
</div>
)}
</div>
{/* Outbound message metadata */}
{message.isoutbound && (
{/* Outbound message metadata for non-system messages */}
{!isSystem && message.isoutbound && (
<div style={{ fontSize: 10 }}>
{i18n.t("messaging.labels.sentby", {
by: message.userid,

View File

@@ -1,6 +1,6 @@
import { LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Input, Spin } from "antd";
import React, { useEffect, useRef, useState } from "react";
import { ExclamationCircleOutlined, LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Alert, Input, Space, Spin, Tooltip } from "antd";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -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(
@@ -56,47 +68,67 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
};
return (
<div className="imex-flex-row" style={{ width: "100%" }}>
<ChatPresetsComponent className="imex-flex-row__margin" />
<ChatMediaSelector
conversation={conversation}
selectedMedia={selectedMedia}
setSelectedMedia={setSelectedMedia}
/>
<span style={{ flex: 1 }}>
<Input.TextArea
className="imex-flex-row__margin imex-flex-row__grow"
allowClear
autoFocus
ref={inputArea}
autoSize={{ minRows: 1, maxRows: 4 }}
value={message}
disabled={isSending}
placeholder={t("messaging.labels.typeamessage")}
onChange={(e) => setMessage(e.target.value)}
onPressEnter={(event) => {
event.preventDefault();
if (!!!event.shiftKey) handleEnter();
}}
/>
</span>
<SendOutlined
className="chat-send-message-button"
// disabled={message === "" || !message}
onClick={handleEnter}
/>
<Spin
style={{ display: `${isSending ? "" : "none"}` }}
indicator={
<LoadingOutlined
style={{
fontSize: 24
}}
spin
<Space direction="vertical" style={{ width: "100%" }} size="middle">
{isOptedOut && (
<Tooltip title={t("consent.text_body")}>
<Alert
showIcon={true}
icon={<ExclamationCircleOutlined />}
message={t("messaging.errors.no_consent")}
type="error"
/>
}
/>
</div>
</Tooltip>
)}
<div className="imex-flex-row" style={{ width: "100%" }}>
{!isOptedOut && (
<>
<ChatPresetsComponent disabled={isSending} className="imex-flex-row__margin" />
<ChatMediaSelector
disabled={isSending}
conversation={conversation}
selectedMedia={selectedMedia}
setSelectedMedia={setSelectedMedia}
/>
</>
)}
<span style={{ flex: 1 }}>
<Input.TextArea
className="imex-flex-row__margin imex-flex-row__grow"
allowClear
autoFocus
ref={inputArea}
autoSize={{ minRows: 1, maxRows: 4 }}
value={message}
disabled={isSending || isOptedOut}
placeholder={t("messaging.labels.typeamessage")}
onChange={(e) => setMessage(e.target.value)}
onPressEnter={(event) => {
event.preventDefault();
if (!event.shiftKey && !isOptedOut) handleEnter();
}}
/>
</span>
{!isOptedOut && (
<SendOutlined
className="chat-send-message-button"
disabled={isSending || message === "" || !message}
onClick={handleEnter}
/>
)}
<Spin
style={{ display: `${isSending ? "" : "none"}` }}
indicator={
<LoadingOutlined
style={{
fontSize: 24
}}
spin
/>
}
/>
</div>
</Space>
);
}

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: false })
? [
{
key: "rescue",
id: "header-rescue",
icon: <PlusCircleOutlined />,
label: t("menus.header.rescueme"),
onClick: () => window.open("https://imexrescue.com/", "_blank")
}
]
: []),
{
key: "rescue-zoho",
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

@@ -395,32 +395,33 @@ export function ScheduleEventComponent({
) : (
<ScheduleManualEvent event={event} />
)}
{event.isintake && HasFeatureAccess({ featureName: "checklist", bodyshop }) ? (
<Link
to={{
pathname: `/manage/jobs/${event.job && event.job.id}/intake`,
search: `?appointmentId=${event.id}`
}}
>
<Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button>
</Link>
) : (
<Popover //open={open}
content={popMenu}
open={popOverVisible}
onOpenChange={setPopOverVisible}
onClick={(e) => {
if (event.job?.id) {
e.stopPropagation();
getJobDetails();
}
}}
getPopupContainer={(trigger) => trigger.parentNode}
trigger="click"
>
<Button disabled={event.arrived}>{t("jobs.actions.intake_quick")}</Button>
</Popover>
)}
{event.job &&
(HasFeatureAccess({ featureName: "checklist", bodyshop }) ? (
<Link
to={{
pathname: `/manage/jobs/${event.job && event.job.id}/intake`,
search: `?appointmentId=${event.id}`
}}
>
<Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button>
</Link>
) : (
<Popover //open={open}
content={popMenu}
open={popOverVisible}
onOpenChange={setPopOverVisible}
onClick={(e) => {
if (event.job?.id) {
e.stopPropagation();
getJobDetails();
}
}}
getPopupContainer={(trigger) => trigger.parentNode}
trigger="click"
>
<Button disabled={event.arrived}>{t("jobs.actions.intake_quick")}</Button>
</Popover>
))}
</Space>
</div>
);

View File

@@ -1,7 +1,7 @@
import { useMutation } from "@apollo/client";
import { Button, Card, Form, Input, Switch } from "antd";
import queryString from "query-string";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate, useParams } from "react-router-dom";
@@ -9,7 +9,6 @@ import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../../../firebase/firebase.utils";
import { MARK_APPOINTMENT_ARRIVED, MARK_LATEST_APPOINTMENT_ARRIVED } from "../../../../graphql/appointments.queries";
import { UPDATE_JOB } from "../../../../graphql/jobs.queries";
import { UPDATE_OWNER } from "../../../../graphql/owners.queries";
import { insertAuditTrail } from "../../../../redux/application/application.actions";
import { selectBodyshop, selectCurrentUser } from "../../../../redux/user/user.selectors";
import AuditTrailMapping from "../../../../utils/AuditTrailMappings";
@@ -32,7 +31,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
const [loading, setLoading] = useState(false);
const [markAptArrived] = useMutation(MARK_APPOINTMENT_ARRIVED);
const [markLatestAptArrived] = useMutation(MARK_LATEST_APPOINTMENT_ARRIVED);
const [updateOwner] = useMutation(UPDATE_OWNER);
const notification = useNotification();
const { jobId } = useParams();
@@ -129,24 +127,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
}
}
if (type === "intake" && job.owner && job.owner.id) {
//Updae Owner Allow to Text
const updateOwnerResult = await updateOwner({
variables: {
ownerId: job.owner.id,
owner: { allow_text_message: values.allow_text_message }
}
});
if (!!updateOwnerResult.errors) {
notification["error"]({
message: t("checklist.errors.complete", {
error: JSON.stringify(result.errors)
})
});
}
}
setLoading(false);
if (!!!result.errors) {
@@ -189,7 +169,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
initialValues={{
...(type === "intake" && {
addToProduction: true,
allow_text_message: job.owner && job.owner.allow_text_message,
scheduled_completion:
(job && job.scheduled_completion && dayjs(job.scheduled_completion)) ||
(job &&
@@ -228,14 +207,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
>
<Switch disabled={readOnly} />
</Form.Item>
<Form.Item
name="allow_text_message"
valuePropName="checked"
label={t("checklist.labels.allow_text_message")}
disabled={readOnly}
>
<Switch disabled={readOnly} />
</Form.Item>
<Form.Item
name="scheduled_completion"
label={t("jobs.fields.scheduled_completion")}

View File

@@ -80,7 +80,7 @@ export function JobEmployeeAssignments({
);
return (
<Popover destroyTooltipOnHide content={popContent} open={visibility}>
<Popover destroyOnHidden content={popContent} open={visibility}>
<Spin spinning={loading}>
<DataLabel label={t("jobs.fields.employee_body")}>
{body ? (

View File

@@ -1,4 +1,4 @@
import { Form, Input, Switch } from "antd";
import { Form, Input } from "antd";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
@@ -129,13 +129,6 @@ export default function JobsCreateOwnerInfoNewComponent() {
<Form.Item label={t("owners.fields.preferred_contact")} name={["owner", "data", "preferred_contact"]}>
<Input disabled={!state.owner.new} />
</Form.Item>
<Form.Item
label={t("owners.fields.allow_text_message")}
valuePropName="checked"
name={["owner", "data", "allow_text_message"]}
>
<Switch disabled={!state.owner.new} />
</Form.Item>
</LayoutFormRow>
</div>
);

View File

@@ -72,7 +72,7 @@ export default function JobsCreateVehicleInfoPredefined({ disabled, form }) {
open={open}
placement="left"
onOpenChange={handleOpenChange}
destroyTooltipOnHide
destroyOnHidden
>
<SearchOutlined style={{ cursor: "pointer" }} />
</Popover>

View File

@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { auth, logImEXEvent } from "../../firebase/firebase.utils";
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
@@ -32,7 +33,6 @@ import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -1078,17 +1078,26 @@ export function JobsDetailHeaderActions({
menuItems.push({
key: "deletejob",
id: "job-actions-deletejob",
label: (
<Popconfirm
title={t("jobs.labels.deleteconfirm")}
okText={t("general.labels.yes")}
cancelText={t("general.labels.no")}
onClick={(e) => e.stopPropagation()}
onConfirm={handleDeleteJob}
>
{t("menus.jobsactions.deletejob")}
</Popconfirm>
)
label:
job.job_watchers.length === 0 ? (
<Popconfirm
title={t("jobs.labels.deleteconfirm")}
okText={t("general.labels.yes")}
cancelText={t("general.labels.no")}
onClick={(e) => e.stopPropagation()}
onConfirm={handleDeleteJob}
>
{t("menus.jobsactions.deletejob")}
</Popconfirm>
) : (
<Popconfirm
title={t("jobs.labels.deletewatchers")}
onClick={(e) => e.stopPropagation()}
showCancel={false}
>
{t("menus.jobsactions.deletejob")}
</Popconfirm>
)
});
}
@@ -1109,8 +1118,8 @@ export function JobsDetailHeaderActions({
<RbacWrapper action="jobs:void" noauth>
<Popconfirm
title={t("jobs.labels.voidjob")}
okText="Yes"
cancelText="No"
okText={t("general.labels.yes")}
cancelText={t("general.labels.no")}
onClick={(e) => e.stopPropagation()}
onConfirm={handleVoidJob}
>

View File

@@ -167,7 +167,18 @@ export function JobsDetailHeaderActionsToggleProduction({
<FormDateTimePickerComponent disabled={jobRO} />
</Form.Item>
<Form.Item name={["actual_delivery"]} label={t("jobs.fields.actual_delivery")}>
<Form.Item
name={["actual_delivery"]}
label={t("jobs.fields.actual_delivery")}
rules={[
{
required: bodyshop.deliverchecklist.actual_delivery
? bodyshop.deliverchecklist.actual_delivery
: false
//message: t("general.validation.required"),
}
]}
>
<FormDateTimePickerComponent disabled={jobRO} />
</Form.Item>
</>

View File

@@ -1,17 +1,20 @@
import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined, WarningFilled } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { Card, Checkbox, Col, Divider, Row, Space, Tag, Tooltip } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { useMutation } from "@apollo/client";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateTimeFormatter } from "../../utils/DateFormatter";
import { DateTimeFormatter, DateTimeFormatterFunction } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import ChatOpenButton from "../chat-open-button/chat-open-button.component";
@@ -24,7 +27,6 @@ import ProductionListColumnComment from "../production-list-columns/production-l
import ProductionListColumnProductionNote from "../production-list-columns/production-list-columns.productionnote.component";
import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component";
import "./jobs-detail-header.styles.scss";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly,
@@ -38,6 +40,14 @@ const mapDispatchToProps = (dispatch) => ({
context: context,
modal: "printCenter"
})
),
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
)
});
@@ -49,7 +59,7 @@ const colSpan = {
xl: { span: 6 }
};
export function JobsDetailHeader({ job, bodyshop, disabled }) {
export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail }) {
const { t } = useTranslation();
const { notification } = useNotification();
const [notesClamped, setNotesClamped] = useState(true);
@@ -66,7 +76,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
const handleCheckboxChange = async (field, checked) => {
const value = checked ? dayjs().toISOString() : null;
try {
await updateJob({
const ret = await updateJob({
variables: {
jobId: job.id,
job: { [field]: value }
@@ -74,6 +84,16 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
refetchQueries: ["GET_JOB_BY_PK"],
awaitRefetchQueries: true
});
insertAuditTrail({
jobid: job.id,
operation: AuditTrailMapping.jobfieldchange(
field,
ret.data.update_jobs.returning[0][field]
? DateTimeFormatterFunction(ret.data.update_jobs.returning[0][field])
: checked
),
type: "jobfieldchange"
});
} catch (error) {
notification.error({
message: t("jobs.errors.saving", { error: error.message })

View File

@@ -42,7 +42,7 @@ export function JobsDocumentsContainer({
variables: { jobId: jobId },
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
skip: Imgproxy.treatment === "on" || !!billId
skip: !!billId
});
if (loading) return <LoadingSpinner />;

View File

@@ -1,14 +1,15 @@
import { Form, Input, Switch } from "antd";
import React from "react";
import { Form, Input, Tooltip } from "antd";
import { CloseCircleFilled } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import FormItemEmail from "../form-items-formatted/email-form-item.component";
import FormItemPhone, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
export default function OwnerDetailFormComponent({ form, loading }) {
export default function OwnerDetailFormComponent({ form, loading, isPhone1OptedOut, isPhone2OptedOut }) {
const { t } = useTranslation();
const { getFieldValue } = form;
return (
<div>
<FormFieldsChanged form={form} />
@@ -26,7 +27,7 @@ export default function OwnerDetailFormComponent({ form, loading }) {
<Input />
</Form.Item>
<Form.Item label={t("owners.fields.accountingid")} name="accountingid">
<Input disabled/>
<Input disabled />
</Form.Item>
</LayoutFormRow>
<LayoutFormRow header={t("owners.forms.address")}>
@@ -50,9 +51,6 @@ export default function OwnerDetailFormComponent({ form, loading }) {
</Form.Item>
</LayoutFormRow>
<LayoutFormRow header={t("owners.forms.contact")}>
<Form.Item label={t("owners.fields.allow_text_message")} name="allow_text_message" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
label={t("owners.fields.ownr_ea")}
name="ownr_ea"
@@ -65,19 +63,55 @@ export default function OwnerDetailFormComponent({ form, loading }) {
>
<FormItemEmail email={getFieldValue("ownr_ea")} />
</Form.Item>
<Form.Item
label={t("owners.fields.ownr_ph1")}
name="ownr_ph1"
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph1")]}
>
<FormItemPhone />
<Form.Item label={t("owners.fields.ownr_ph1")} style={{ marginBottom: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Form.Item
name="ownr_ph1"
noStyle
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph1")]}
>
<Input style={{ flex: 1, minWidth: "150px" }} />
</Form.Item>
{isPhone1OptedOut && (
<Tooltip title={t("consent.text_body")}>
<CloseCircleFilled
style={{
color: "#ff4d4f",
fontSize: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%"
}}
/>
</Tooltip>
)}
</div>
</Form.Item>
<Form.Item
label={t("owners.fields.ownr_ph2")}
name="ownr_ph2"
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph2")]}
>
<FormItemPhone />
<Form.Item label={t("owners.fields.ownr_ph2")} style={{ marginBottom: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Form.Item
name="ownr_ph2"
noStyle
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph2")]}
>
<Input style={{ flex: 1, minWidth: "150px" }} />
</Form.Item>
{isPhone2OptedOut && (
<Tooltip title={t("consent.text_body")}>
<CloseCircleFilled
style={{
color: "#ff4d4f",
fontSize: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%"
}}
/>
</Tooltip>
)}
</div>
</Form.Item>
<Form.Item label={t("owners.fields.preferred_contact")} name="preferred_contact">
<Input />

View File

@@ -1,69 +1,115 @@
import { Button, Form, Popconfirm } from "antd";
import { PageHeader } from "@ant-design/pro-layout";
import React, { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation } from "@apollo/client";
import { useApolloClient, useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { DELETE_OWNER, UPDATE_OWNER } from "../../graphql/owners.queries";
import { selectBodyshop } from "../../redux/user/user.selectors"; // Adjust path
import { phoneNumberOptOutService } from "../../utils/phoneOptOutService.js"; // Adjust path
import OwnerDetailFormComponent from "./owner-detail-form.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { phone } from "phone"; // Import phone utility for formatting
function OwnerDetailFormContainer({ owner, refetch }) {
// Connect to Redux to access bodyshop
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
function OwnerDetailFormContainer({ owner, refetch, bodyshop }) {
const { t } = useTranslation();
const [form] = Form.useForm();
const history = useNavigate();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [optedOutPhones, setOptedOutPhones] = useState(new Set());
const [updateOwner] = useMutation(UPDATE_OWNER);
const [deleteOwner] = useMutation(DELETE_OWNER);
const notification = useNotification();
const apolloClient = useApolloClient();
// Fetch opt-out status on mount
useEffect(() => {
const fetchOptOutStatus = async () => {
if (bodyshop?.id && bodyshop?.messagingservicesid && (owner?.ownr_ph1 || owner?.ownr_ph2)) {
const phoneNumbers = [owner.ownr_ph1, owner.ownr_ph2].filter(Boolean);
const optOutSet = await phoneNumberOptOutService(apolloClient, bodyshop.id, phoneNumbers);
setOptedOutPhones(optOutSet);
} else {
setOptedOutPhones(new Set());
}
};
fetchOptOutStatus();
}, [apolloClient, bodyshop?.id, bodyshop?.messagingservicesid, owner?.ownr_ph1, owner?.ownr_ph2]);
// Reset form fields when owner changes
useEffect(() => {
form.setFieldsValue({
ownr_ph1: owner?.ownr_ph1,
ownr_ph2: owner?.ownr_ph2,
...owner
});
}, [owner, form]);
const handleDelete = async () => {
setLoading(true);
const result = await deleteOwner({
variables: { id: owner.id }
});
console.log(result);
if (result.errors) {
notification["error"]({
try {
const result = await deleteOwner({
variables: { id: owner.id }
});
if (result.errors) {
notification.error({
message: t("owners.errors.deleting", {
error: JSON.stringify(result.errors)
})
});
} else {
notification.success({
message: t("owners.successes.delete")
});
navigate(`/manage/owners`);
}
} catch (error) {
notification.error({
message: t("owners.errors.deleting", {
error: JSON.stringify(result.errors)
error: error.message
})
});
} finally {
setLoading(false);
} else {
notification["success"]({
message: t("owners.successes.delete")
});
setLoading(false);
history(`/manage/owners`);
}
};
const handleFinish = async (values) => {
setLoading(true);
const result = await updateOwner({
variables: { ownerId: owner.id, owner: values }
});
if (!!result.errors) {
notification["error"]({
try {
const result = await updateOwner({
variables: { ownerId: owner.id, owner: values }
});
if (result.errors) {
notification.error({
message: t("owners.errors.saving", {
error: JSON.stringify(result.errors)
})
});
} else {
notification.success({
message: t("owners.successes.save")
});
if (refetch) await refetch();
form.resetFields();
}
} catch (error) {
notification.error({
message: t("owners.errors.saving", {
error: JSON.stringify(result.errors)
error: error.message
})
});
} finally {
setLoading(false);
return;
}
notification["success"]({
message: t("owners.successes.save")
});
if (refetch) await refetch();
form.resetFields();
form.resetFields();
setLoading(false);
};
return (
@@ -72,6 +118,7 @@ function OwnerDetailFormContainer({ owner, refetch }) {
title={t("menus.header.owners")}
extra={[
<Popconfirm
key="delete"
trigger="click"
onConfirm={handleDelete}
disabled={owner.jobs.length !== 0}
@@ -81,16 +128,29 @@ function OwnerDetailFormContainer({ owner, refetch }) {
{t("general.actions.delete")}
</Button>
</Popconfirm>,
<Button type="primary" loading={loading} onClick={() => form.submit()}>
<Button key="save" type="primary" loading={loading} onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
]}
/>
<Form form={form} onFinish={handleFinish} autoComplete="off" layout="vertical" initialValues={owner}>
<OwnerDetailFormComponent loading={loading} form={form} />
<OwnerDetailFormComponent
loading={loading}
form={form}
isPhone1OptedOut={
bodyshop?.messagingservicesid &&
owner?.ownr_ph1 &&
optedOutPhones.has(phone(owner.ownr_ph1, "CA").phoneNumber?.replace(/^\+1/, ""))
}
isPhone2OptedOut={
bodyshop?.messagingservicesid &&
owner?.ownr_ph2 &&
optedOutPhones.has(phone(owner.ownr_ph2, "CA").phoneNumber?.replace(/^\+1/, ""))
}
/>
</Form>
</>
);
}
export default OwnerDetailFormContainer;
export default connect(mapStateToProps)(OwnerDetailFormContainer);

View File

@@ -75,7 +75,7 @@ export function PartsOrderBackorderEta({
);
return (
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}>
<Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
<DateFormatter>{backordered_eta}</DateFormatter>
{isAlreadyBackordered && <CalendarFilled style={{ cursor: "pointer" }} onClick={handlePopover} />}
{loading && <Spin />}

View File

@@ -84,7 +84,7 @@ export function PartsOrderLineBackorderButton({ partsOrderStatus, partsLineId, j
);
return (
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}>
<Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
<Button loading={loading} onClick={handlePopover}>
{isAlreadyBackordered ? t("parts_orders.actions.receive") : t("parts_orders.actions.backordered")}
</Button>

View File

@@ -0,0 +1,146 @@
import { useQuery } from "@apollo/client";
import { Input, Table, Typography } from "antd";
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 { TimeAgoFormatter } from "../../utils/DateFormatter";
import ChatOpenButton from "../chat-open-button/chat-open-button.component";
import { useTranslation } from "react-i18next";
import { useState } from "react";
const { Paragraph } = Typography;
// Commented out Associated Owners section for now
//import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
//import { Link } from "react-router-dom";
//import { useMemo, useState } from "react";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const mapDispatchToProps = () => ({});
function PhoneNumberConsentList({ bodyshop, currentUser }) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
// Fetch opt-out phone numbers
const { loading: optOutLoading, data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, {
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined },
fetchPolicy: "network-only"
});
// Commented out Associated Owners section for now
/*// Prepare phone numbers for owner query
const phoneNumbers = useMemo(() => {
return optOutData?.phone_number_opt_out?.map((item) => item.phone_number) || [];
}, [optOutData?.phone_number_opt_out]);
const allPhoneNumbers = useMemo(() => {
const normalized = phoneNumbers;
const withPlusOne = phoneNumbers.map((num) => `+1${num}`);
return [...normalized, ...withPlusOne].filter(Boolean);
}, [phoneNumbers]);
// Fetch owners for all phone numbers
const { loading: ownersLoading, data: ownersData } = useQuery(SEARCH_OWNERS_BY_PHONE_NUMBERS, {
variables: { bodyshopid: bodyshop.id, phone_numbers: allPhoneNumbers },
skip: allPhoneNumbers.length === 0 || !bodyshop.id,
fetchPolicy: "network-only"
});
// Map phone numbers to their associated owners and identify phone field
const getAssociatedOwners = (phoneNumber) => {
if (!ownersData?.owners) return [];
const normalizedPhone = phoneNumber.replace(/^\+1/, "");
return ownersData.owners
.filter(
(owner) =>
owner.ownr_ph1 === phoneNumber ||
owner.ownr_ph2 === phoneNumber ||
owner.ownr_ph1 === normalizedPhone ||
owner.ownr_ph2 === normalizedPhone ||
owner.ownr_ph1 === `+1${phoneNumber}` ||
owner.ownr_ph2 === `+1${phoneNumber}`
)
.map((owner) => ({
...owner,
phoneField:
[owner.ownr_ph1, owner.ownr_ph2].includes(phoneNumber) ||
[owner.ownr_ph1, owner.ownr_ph2].includes(normalizedPhone) ||
[owner.ownr_ph1, owner.ownr_ph2].includes(`+1${phoneNumber}`)
? owner.ownr_ph1 === phoneNumber ||
owner.ownr_ph1 === normalizedPhone ||
owner.ownr_ph1 === `+1${phoneNumber}`
? t("consent.phone_1")
: t("consent.phone_2")
: null
}));
};*/
const columns = [
{
title: t("consent.phone_number"),
dataIndex: "phone_number",
render: (text) => <ChatOpenButton phone={text} />,
sorter: (a, b) => a.phone_number.localeCompare(b.phone_number)
},
// Commented out Associated Owners section for now
/*{
title: t("consent.associated_owners"),
dataIndex: "phone_number",
render: (phoneNumber) => {
const owners = getAssociatedOwners(phoneNumber);
if (!owners || owners.length === 0) {
return t("consent.no_owners");
}
return owners.map((owner) => (
<div key={owner.id}>
<Space direction="horizontal">
<Link to={"/manage/owners/" + owner.id}>
<OwnerNameDisplay ownerObject={owner} />
</Link>
({owner.phoneField})
</Space>
</div>
));
},
sorter: (a, b) => {
const aOwners = getAssociatedOwners(a.phone_number);
const bOwners = getAssociatedOwners(b.phone_number);
const aName = aOwners[0] ? `${aOwners[0].ownr_fn} ${aOwners[0].ownr_ln}` : "";
const bName = bOwners[0] ? `${bOwners[0].ownr_fn} ${bOwners[0].ownr_ln}` : "";
return aName.localeCompare(bName);
}
},*/
{
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>
<Paragraph>{t("consent.text_body")}</Paragraph>
<Input.Search
placeholder={t("general.labels.search")}
onSearch={(value) => setSearch(value)}
style={{ marginBottom: 16 }}
/>
<Table
columns={columns}
dataSource={optOutData?.phone_number_opt_out}
loading={optOutLoading /* || ownersLoading*/}
rowKey="id"
style={{ marginTop: 16 }}
/>
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(PhoneNumberConsentList);

View File

@@ -1,7 +1,15 @@
import { Card, Col, Form, Radio, Row } from "antd";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../../redux/user/user.selectors";
import { HasFeatureAccess } from "../../feature-wrapper/feature-wrapper.component";
const LayoutSettings = ({ t }) => (
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const LayoutSettings = ({ t, bodyshop }) => (
<Card title={t("production.settings.layout")} style={{ maxWidth: "100%", overflowX: "auto" }}>
<Row gutter={[16, 16]}>
{[
@@ -30,14 +38,18 @@ const LayoutSettings = ({ t }) => (
{ value: false, label: t("production.labels.wide") }
]
},
{
name: "cardcolor",
label: t("production.labels.cardcolor"),
options: [
{ value: true, label: t("production.labels.on") },
{ value: false, label: t("production.labels.off") }
]
},
...(HasFeatureAccess({ bodyshop, featureName: "smartscheduling" })
? [
{
name: "cardcolor",
label: t("production.labels.cardcolor"),
options: [
{ value: true, label: t("production.labels.on") },
{ value: false, label: t("production.labels.off") }
]
}
]
: []),
{
name: "kiosk",
label: t("production.labels.kiosk_mode"),
@@ -67,4 +79,4 @@ LayoutSettings.propTypes = {
t: PropTypes.func.isRequired
};
export default LayoutSettings;
export default connect(mapStateToProps)(LayoutSettings);

View File

@@ -140,7 +140,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
if (record[type]) theEmployee = bodyshop.employees.find((e) => e.id === record[type]);
return (
<Popover destroyTooltipOnHide content={popContent} open={visibility}>
<Popover destroyOnHidden content={popContent} open={visibility}>
<Spin spinning={loading}>
{record[type] ? (
<div>

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

@@ -107,7 +107,7 @@ export default function TimeTicketCalculatorComponent({
open={visible}
onOpenChange={handleOpenChange}
placement="right"
destroyTooltipOnHide
destroyOnHidden
>
<Button onClick={(e) => e.preventDefault()}>
<Space>

View File

@@ -312,7 +312,6 @@ export const QUERY_INTAKE_CHECKLIST = gql`
intakechecklist
status
owner {
allow_text_message
id
}
labhrs: joblines_aggregate(where: { _and: [{ mod_lbr_ty: { _neq: "LAR" } }, { removed: { _eq: false } }] }) {

View File

@@ -43,6 +43,7 @@ export const CONVERSATION_SUBSCRIPTION_BY_PK = gql`
id
status
text
is_system
isoutbound
image
image_path
@@ -77,6 +78,7 @@ export const GET_CONVERSATION_DETAILS = gql`
id
status
text
is_system
isoutbound
image
image_path

View File

@@ -874,7 +874,6 @@ export const QUERY_JOB_CARD_DETAILS = gql`
}
owner {
id
allow_text_message
preferred_contact
tax_number
}
@@ -2071,7 +2070,6 @@ export const QUERY_JOB_CHECKLISTS = gql`
production_vars
owner {
id
allow_text_message
}
bodyshop {
id
@@ -2428,7 +2426,6 @@ export const QUERY_PARTS_QUEUE_CARD_DETAILS = gql`
ownr_ph2
owner {
id
allow_text_message
preferred_contact
tax_number
}

View File

@@ -49,7 +49,6 @@ export const QUERY_OWNER_BY_ID = gql`
owners_by_pk(id: $id) {
id
accountingid
allow_text_message
ownr_addr1
ownr_addr2
ownr_co_nm
@@ -104,7 +103,6 @@ export const QUERY_ALL_OWNERS = gql`
query QUERY_ALL_OWNERS {
owners {
id
allow_text_message
created_at
ownr_addr1
ownr_addr2
@@ -129,7 +127,6 @@ export const QUERY_ALL_OWNERS_PAGINATED = gql`
query QUERY_ALL_OWNERS_PAGINATED($search: String, $offset: Int, $limit: Int, $order: [owners_order_by!]!) {
search_owners(args: { search: $search }, offset: $offset, limit: $limit, order_by: $order) {
id
allow_text_message
created_at
ownr_addr1
ownr_addr2

View File

@@ -0,0 +1,64 @@
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
}
}
`;
export const GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS = gql`
query GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS($bodyshopid: uuid!, $phone_numbers: [String!]) {
phone_number_opt_out(
where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _in: $phone_numbers } }
) {
id
bodyshopid
phone_number
created_at
updated_at
}
}
`;
export const SEARCH_OWNERS_BY_PHONE_NUMBERS = gql`
query SEARCH_OWNERS_BY_PHONE_NUMBERS($bodyshopid: uuid!, $phone_numbers: [String!]) {
owners(
where: {
shopid: { _eq: $bodyshopid },
_or: [
{ ownr_ph1: { _in: $phone_numbers } },
{ ownr_ph2: { _in: $phone_numbers } }
]
}
) {
id
ownr_fn
ownr_ln
ownr_co_nm
ownr_ph1
ownr_ph2
__typename
}
}
`;

View File

@@ -114,7 +114,6 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
if (!!!job.ownerid) {
ownerData = job.owner.data;
ownerData.shopid = bodyshop.id;
delete ownerData.allow_text_message;
delete ownerData.preferred_contact;
delete job.ownerid;
} else {

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,16 @@ export function ShopPage({ bodyshop, setSelectedHeader, setBreadcrumbs }) {
children: <ShopCsiConfig />
});
}
if (bodyshop.messagingservicesid) {
// 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",
@@ -774,7 +775,6 @@
},
"labels": {
"addtoproduction": "Add Job to Production?",
"allow_text_message": "Permission to Text?",
"checklist": "Checklist",
"printpack": "Job Intake Print Pack",
"removefromproduction": "Remove Job from Production?"
@@ -2031,9 +2031,10 @@
"stands": "Stands",
"waived": "Waived"
},
"deleteconfirm": "Are you sure you want to delete this Job? This cannot be undone. ",
"deleteconfirm": "Are you sure you want to delete this Job? This cannot be undone.",
"deletedelivery": "Delete Delivery Checklist",
"deleteintake": "Delete Intake Checklist",
"deletewatchers": "Remove Watchers before deleting this Job.",
"deliverchecklist": "Deliver Checklist",
"difference": "Difference",
"diskscan": "Scan Disk for Estimates",
@@ -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 opted-out of Messaging."
},
"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": "Opted-out"
},
"render": {
"conversation_list": "Conversation List"
@@ -2518,7 +2523,6 @@
"fields": {
"accountingid": "Accounting ID",
"address": "Address",
"allow_text_message": "Permission to Text?",
"name": "Name",
"note": "Owner Note",
"ownr_addr1": "Address",
@@ -3099,6 +3103,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 +3867,18 @@
"validation": {
"unique_vendor_name": "You must enter a unique vendor name."
}
},
"consent": {
"phone_number": "Phone Number",
"associated_owners": "Associated Owners",
"created_at": "Opt-Out Date",
"no_owners": "No Associated Owners",
"phone_1": "Phone 1",
"phone_2": "Phone 2",
"text_body": "Users can opt out of receiving SMS messages by replying with keywords such as STOP, UNSUBSCRIBE, CANCEL, END, QUIT, STOPALL, REVOKE and OPTOUT. To opt back in, users can reply with START, YES, or UNSTOP. Even after opting out, users can still send messages to us, which will be received and processed as needed. Ensure customers are informed to reply with these keywords to manage their messaging preferences. After opting out, users receive a confirmation message and will not receive further messages until they opt back in."
},
"settings": {
"title": "Phone Number Opt-Out List"
}
}
}

View File

@@ -656,6 +656,7 @@
}
},
"labels": {
"consent_settings": "",
"2tiername": "",
"2tiersetup": "",
"2tiersource": "",
@@ -774,7 +775,6 @@
},
"labels": {
"addtoproduction": "",
"allow_text_message": "",
"checklist": "",
"printpack": "",
"removefromproduction": ""
@@ -2034,6 +2034,7 @@
"deleteconfirm": "",
"deletedelivery": "",
"deleteintake": "",
"deletewatchers": "",
"deliverchecklist": "",
"difference": "",
"diskscan": "",
@@ -2301,8 +2302,10 @@
"productionlist": "",
"readyjobs": "",
"recent": "",
"remoteassist": "",
"reportcenter": "",
"rescueme": "",
"rescuemezoho": "",
"schedule": "Programar",
"scoreboard": "",
"search": {
@@ -2377,7 +2380,8 @@
"errors": {
"invalidphone": "",
"noattachedjobs": "",
"updatinglabel": ""
"updatinglabel": "",
"no_consent": ""
},
"labels": {
"addlabel": "",
@@ -2393,7 +2397,8 @@
"selectmedia": "",
"sentby": "",
"typeamessage": "Enviar un mensaje...",
"unarchive": ""
"unarchive": "",
"no_consent": ""
},
"render": {
"conversation_list": ""
@@ -2498,7 +2503,8 @@
},
"tooltips": {
"job-watchers": "",
"not-employee": ""
"not-employee": "",
"not-employee-notifications": ""
}
},
"owner": {
@@ -2519,7 +2525,6 @@
"fields": {
"accountingid": "",
"address": "Dirección",
"allow_text_message": "Permiso de texto?",
"name": "Nombre",
"note": "",
"ownr_addr1": "Dirección",
@@ -3100,6 +3105,7 @@
"credits_not_received_date_vendorid": "",
"csi": "",
"customer_list": "",
"customer_list_excel": "",
"cycle_time_analysis": "",
"estimates_written_converted": "",
"estimator_detail": "",
@@ -3863,6 +3869,18 @@
"validation": {
"unique_vendor_name": ""
}
},
"consent": {
"phone_number": "",
"associated_owners": "",
"created_at": "",
"no_owners": "",
"phone_1": "",
"phone_2": "",
"text_body": ""
},
"settings": {
"title": ""
}
}
}

View File

@@ -656,6 +656,7 @@
}
},
"labels": {
"consent_settings": "",
"2tiername": "",
"2tiersetup": "",
"2tiersource": "",
@@ -774,7 +775,6 @@
},
"labels": {
"addtoproduction": "",
"allow_text_message": "",
"checklist": "",
"printpack": "",
"removefromproduction": ""
@@ -2034,6 +2034,7 @@
"deleteconfirm": "",
"deletedelivery": "",
"deleteintake": "",
"deletewatchers": "",
"deliverchecklist": "",
"difference": "",
"diskscan": "",
@@ -2301,8 +2302,10 @@
"productionlist": "",
"readyjobs": "",
"recent": "",
"remoteassist": "",
"reportcenter": "",
"rescueme": "",
"rescuemezoho": "",
"schedule": "Programme",
"scoreboard": "",
"search": {
@@ -2377,7 +2380,8 @@
"errors": {
"invalidphone": "",
"noattachedjobs": "",
"updatinglabel": ""
"updatinglabel": "",
"no_consent": ""
},
"labels": {
"addlabel": "",
@@ -2393,7 +2397,8 @@
"selectmedia": "",
"sentby": "",
"typeamessage": "Envoyer un message...",
"unarchive": ""
"unarchive": "",
"no_consent": ""
},
"render": {
"conversation_list": ""
@@ -2498,7 +2503,8 @@
},
"tooltips": {
"job-watchers": "",
"not-employee": ""
"not-employee": "",
"not-employee-notifications": ""
}
},
"owner": {
@@ -2519,7 +2525,6 @@
"fields": {
"accountingid": "",
"address": "Adresse",
"allow_text_message": "Autorisation de texte?",
"name": "Prénom",
"note": "",
"ownr_addr1": "Adresse",
@@ -3100,6 +3105,7 @@
"credits_not_received_date_vendorid": "",
"csi": "",
"customer_list": "",
"customer_list_excel": "",
"cycle_time_analysis": "",
"estimates_written_converted": "",
"estimator_detail": "",
@@ -3863,6 +3869,18 @@
"validation": {
"unique_vendor_name": ""
}
},
"consent": {
"phone_number": "Phone Number",
"associated_owners": "Associated Owners",
"created_at": "Opt-Out Date",
"no_owners": "No Associated Owners",
"phone_1": "Phone 1",
"phone_2": "Phone 2",
"text_body": ""
},
"settings": {
"title": ""
}
}
}

View File

@@ -1,6 +1,5 @@
import { Tooltip } from "antd";
import dayjs from "../utils/day";
import React from "react";
export function DateFormatter(props) {
return props.children ? dayjs(props.children).format(props.includeDay ? "ddd MM/DD/YYYY" : "MM/DD/YYYY") : null;

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

@@ -0,0 +1,48 @@
import { phone } from "phone";
import { GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS } from "../graphql/phone-number-opt-out.queries";
/**
* Check if phone numbers are opted out for a given bodyshop
* @param {Object} apolloClient - Apollo Client instance
* @param {string} bodyshopId - The ID of the bodyshop
* @param {string[]} phoneNumbers - Array of phone numbers to check
* @returns {Promise<Set<string>>} - Set of normalized opted-out phone numbers
*/
export const phoneNumberOptOutService = async (apolloClient, bodyshopId, phoneNumbers) => {
if (!apolloClient || !bodyshopId || !phoneNumbers?.length) {
return new Set();
}
// Normalize phone numbers (remove +1 for CA numbers)
const normalizedPhones = phoneNumbers
.filter(Boolean)
.map((num) => phone(num, "CA").phoneNumber?.replace(/^\+1/, ""))
.filter(Boolean);
if (!normalizedPhones.length) {
return new Set();
}
const optedOutPhones = new Set();
try {
const { data } = await apolloClient.query({
query: GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS,
variables: {
bodyshopid: bodyshopId,
phone_numbers: normalizedPhones // Array of phone numbers
},
fetchPolicy: "network-only"
});
if (data?.phone_number_opt_out?.length) {
data.phone_number_opt_out.forEach((optOut) => {
optedOutPhones.add(optOut.phone_number);
});
}
} catch (error) {
console.error("Error checking opt-out statuses:", error);
}
return optedOutPhones;
};

View File

@@ -2681,6 +2681,9 @@
- active:
_eq: true
allow_aggregations: true
- table:
name: integration_log
schema: public
- table:
name: inventory
schema: public
@@ -4739,6 +4742,7 @@
- id
- image
- image_path
- is_system
- isoutbound
- msid
- read
@@ -5861,6 +5865,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;

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"."messages" add column "is_system" boolean
-- null default 'false';

View File

@@ -0,0 +1,2 @@
alter table "public"."messages" add column "is_system" boolean
null default 'false';

View File

@@ -10028,25 +10028,6 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>allow_text_message</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-ES</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>checklist</name>
<description/>
@@ -33000,25 +32981,6 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>allow_text_message</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-ES</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>name</name>
<description/>

1470
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.817.0",
"@aws-sdk/client-elasticache": "^3.817.0",
"@aws-sdk/client-s3": "^3.817.0",
"@aws-sdk/client-secrets-manager": "^3.817.0",
"@aws-sdk/client-ses": "^3.817.0",
"@aws-sdk/credential-provider-node": "^3.817.0",
"@aws-sdk/lib-storage": "^3.817.0",
"@aws-sdk/s3-request-presigner": "^3.817.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.53.0",
"dinero.js": "^1.9.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
@@ -52,7 +52,7 @@
"juice": "^11.0.1",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"moment-timezone": "^0.5.48",
"moment-timezone": "^0.6.0",
"multer": "^1.4.5-lts.1",
"node-persist": "^4.0.4",
"nodemailer": "^6.10.0",
@@ -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

@@ -282,6 +282,7 @@ const applySocketIO = async ({ server, app }) => {
logger.log("Redis connections closed.", "INFO", "redis", "api");
});
// IO Redis
const ioRedis = new Server(server, {
path: "/wss",
adapter: createAdapter(pubClient, subClient),

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, jobid) {
const items = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From Item where active=true maxresults 1000`),
method: "POST",
@@ -432,6 +486,15 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryItems",
status: items.response?.status,
bodyshopid,
jobid: jobid,
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 +503,15 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryTaxCodes",
status: taxCodes.response?.status,
bodyshopid,
jobid: jobid,
email: req.user.email
})
const classes = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From Class`),
method: "POST",
@@ -448,7 +519,15 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryClasses",
status: classes.response?.status,
bodyshopid,
jobid: jobid,
email: req.user.email
})
const taxCodeMapping = {};
taxCodes.json &&
@@ -483,7 +562,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, job.id);
const InvoiceLineAdd = CreateInvoiceLines({
bodyshop,
jobs_by_pk: job,
@@ -499,57 +578,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 +652,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.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) {
@@ -598,7 +684,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 +702,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 +777,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,66 @@ 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
}
}
`;
exports.INSERT_PHONE_NUMBER_OPT_OUT = `
mutation INSERT_PHONE_NUMBER_OPT_OUT($optOutInput: [phone_number_opt_out_insert_input!]!) {
insert_phone_number_opt_out(objects: $optOutInput, on_conflict: { constraint: phone_number_consent_bodyshopid_phone_number_key, update_columns: [updated_at] }) {
affected_rows
returning {
id
bodyshopid
phone_number
created_at
updated_at
}
}
}
`;
// Query to check if a phone number is opted out
exports.CHECK_PHONE_NUMBER_OPT_OUT = `
query CHECK_PHONE_NUMBER_OPT_OUT($bodyshopid: uuid!, $phone_number: String!) {
phone_number_opt_out(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
id
bodyshopid
phone_number
created_at
updated_at
}
}
`;
// Query to check if a phone number is opted out
exports.CHECK_PHONE_NUMBER_OPT_OUT = `
query CHECK_PHONE_NUMBER_OPT_OUT($bodyshopid: uuid!, $phone_number: String!) {
phone_number_opt_out(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
id
bodyshopid
phone_number
created_at
updated_at
}
}
`;
// Mutation to delete a phone number opt-out record
exports.DELETE_PHONE_NUMBER_OPT_OUT = `
mutation DELETE_PHONE_NUMBER_OPT_OUT($bodyshopid: uuid!, $phone_number: String!) {
delete_phone_number_opt_out(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
affected_rows
returning {
id
bodyshopid
phone_number
}
}
}
`;

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,36 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const client = require("../graphql-client/graphql-client").client;
const queries = require("../graphql-client/queries");
const {
FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID,
UNARCHIVE_CONVERSATION,
CREATE_CONVERSATION,
INSERT_MESSAGE,
CHECK_PHONE_NUMBER_OPT_OUT,
DELETE_PHONE_NUMBER_OPT_OUT,
INSERT_PHONE_NUMBER_OPT_OUT
} = require("../graphql-client/queries");
const { phone } = require("phone");
const { admin } = require("../firebase/firebase-handler");
const logger = require("../utils/logger");
const InstanceManager = require("../utils/instanceMgr").default;
exports.receive = async (req, res) => {
// Note: When we handle different languages, we might need to adjust these keywords accordingly.
const optInKeywords = ["START", "YES", "UNSTOP"];
const optOutKeywords = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT", "REVOKE", "OPTOUT"];
// System Message text, will also need to be localized if we support multiple languages
const systemMessageOptions = {
optIn: "Customer has opted-in",
optOut: "Customer has opted-out"
};
/**
* Receive SMS messages from Twilio and process them
* @param req
* @param res
* @returns {Promise<*>}
*/
const receive = async (req, res) => {
const {
logger,
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
@@ -20,7 +39,7 @@ exports.receive = async (req, res) => {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body)
image_path: generateMediaArray(req.body, logger)
};
logger.log("sms-inbound", "DEBUG", "api", null, loggerData);
@@ -35,7 +54,7 @@ exports.receive = async (req, res) => {
try {
// Step 1: Find the bodyshop and existing conversation
const response = await client.request(queries.FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, {
const response = await client.request(FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, {
mssid: req.body.MessagingServiceSid,
phone: phone(req.body.From).phoneNumber
});
@@ -45,37 +64,27 @@ exports.receive = async (req, res) => {
}
const bodyshop = response.bodyshops[0];
const normalizedPhone = phone(req.body.From).phoneNumber.replace(/^\+1/, ""); // Normalize phone number (remove +1 for CA numbers)
const messageText = (req.body.Body || "").trim().toUpperCase();
// Sort conversations by `updated_at` (or `created_at`) and pick the last one
// Step 2: Process conversation
const sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
const existingConversation = sortedConversations.length
? sortedConversations[sortedConversations.length - 1]
: null;
let conversationid;
let newMessage = {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body),
isoutbound: false,
userid: null // Add additional fields as necessary
};
if (existingConversation) {
// Use the existing conversation
conversationid = existingConversation.id;
// Unarchive the conversation if necessary
if (existingConversation.archived) {
await client.request(queries.UNARCHIVE_CONVERSATION, {
await client.request(UNARCHIVE_CONVERSATION, {
id: conversationid,
archived: false
});
}
} else {
// Create a new conversation
const newConversationResponse = await client.request(queries.CREATE_CONVERSATION, {
const newConversationResponse = await client.request(CREATE_CONVERSATION, {
conversation: {
bodyshopid: bodyshop.id,
phone_num: phone(req.body.From).phoneNumber,
@@ -86,13 +95,137 @@ exports.receive = async (req, res) => {
conversationid = createdConversation.id;
}
// Ensure `conversationid` is added to the message
newMessage.conversationid = conversationid;
// Step 3: Handle opt-in or opt-out keywords
let systemMessageText = "";
let socketEventType = "";
// Step 3: Insert the message into the conversation
const insertresp = await client.request(queries.INSERT_MESSAGE, {
if (optInKeywords.includes(messageText) || optOutKeywords.includes(messageText)) {
// Check if the phone number is in phone_number_opt_out
const optOutCheck = await client.request(CHECK_PHONE_NUMBER_OPT_OUT, {
bodyshopid: bodyshop.id,
phone_number: normalizedPhone
});
// Opt In
if (optInKeywords.includes(messageText)) {
// Handle opt-in
if (optOutCheck.phone_number_opt_out.length > 0) {
// Phone number is opted out; delete the record
const deleteResponse = await client.request(DELETE_PHONE_NUMBER_OPT_OUT, {
bodyshopid: bodyshop.id,
phone_number: normalizedPhone
});
logger.log("sms-opt-in-success", "INFO", "api", null, {
msid: req.body.SmsMessageSid,
bodyshopid: bodyshop.id,
phone_number: normalizedPhone,
affected_rows: deleteResponse.delete_phone_number_opt_out.affected_rows
});
systemMessageText = systemMessageOptions.optIn;
socketEventType = "phone-number-opted-in";
}
}
// Opt Out
else if (optOutKeywords.includes(messageText)) {
// Handle opt-out
if (optOutCheck.phone_number_opt_out.length === 0) {
// Phone number is not opted out; insert a new record
const now = new Date().toISOString();
const optOutInput = {
bodyshopid: bodyshop.id,
phone_number: normalizedPhone,
created_at: now,
updated_at: now
};
const insertResponse = await client.request(INSERT_PHONE_NUMBER_OPT_OUT, {
optOutInput: [optOutInput]
});
logger.log("sms-opt-out-success", "INFO", "api", null, {
msid: req.body.SmsMessageSid,
bodyshopid: bodyshop.id,
phone_number: normalizedPhone,
affected_rows: insertResponse.insert_phone_number_opt_out.affected_rows
});
systemMessageText = systemMessageOptions.optOut;
socketEventType = "phone-number-opted-out";
}
}
// Insert system message if an opt-in or opt-out action was taken
if (systemMessageText) {
const systemMessage = {
msid: `SYS_${req.body.SmsMessageSid}_${Date.now()}`, // Unique ID for system message
text: systemMessageText,
conversationid,
isoutbound: false,
userid: null,
image: false,
image_path: null,
is_system: true
};
const systemMessageResponse = await client.request(INSERT_MESSAGE, {
msg: systemMessage,
conversationid
});
const insertedSystemMessage = systemMessageResponse.insert_messages.returning[0];
// Emit WebSocket events for system message
const broadcastRoom = getBodyshopRoom(bodyshop.id);
const conversationRoom = getBodyshopConversationRoom({
bodyshopId: bodyshop.id,
conversationId: conversationid
});
const systemPayload = {
isoutbound: false,
conversationId: conversationid,
updated_at: insertedSystemMessage.updated_at,
msid: insertedSystemMessage.msid,
existingConversation: !!existingConversation,
newConversation: !existingConversation ? insertedSystemMessage.conversation : null
};
ioRedis.to(broadcastRoom).emit("new-message-summary", {
...systemPayload,
summary: true
});
ioRedis.to(conversationRoom).emit("new-message-detailed", {
newMessage: insertedSystemMessage,
...systemPayload,
summary: false
});
// Emit opt-in or opt-out event
ioRedis.to(broadcastRoom).emit(socketEventType, {
bodyshopid: bodyshop.id,
phone_number: normalizedPhone
});
}
}
// Step 4: Insert the original message
const newMessage = {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body, logger),
isoutbound: false,
userid: null,
conversationid,
is_system: false
};
const insertresp = await client.request(INSERT_MESSAGE, {
msg: newMessage,
conversationid: conversationid
conversationid
});
const message = insertresp?.insert_messages?.returning?.[0];
@@ -102,8 +235,7 @@ exports.receive = async (req, res) => {
throw new Error("Conversation data is missing from the response.");
}
// Step 4: Notify clients through Redis
const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id);
// Step 5: Notify clients for original message
const conversationRoom = getBodyshopConversationRoom({
bodyshopId: conversation.bodyshop.id,
conversationId: conversation.id
@@ -113,9 +245,11 @@ exports.receive = async (req, res) => {
isoutbound: false,
conversationId: conversation.id,
updated_at: message.updated_at,
msid: message.sid
msid: message.msid
};
const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id);
ioRedis.to(broadcastRoom).emit("new-message-summary", {
...commonPayload,
existingConversation: !!existingConversation,
@@ -131,13 +265,13 @@ exports.receive = async (req, res) => {
summary: false
});
// Step 5: Send FCM notification
// Step 6: Send FCM notification
const fcmresp = await admin.messaging().send({
topic: `${message.conversation.bodyshop.imexshopid}-messaging`,
notification: {
title: InstanceManager({
imex: `ImEX Online Message - ${message.conversation.phone_num}`,
rome: `Rome Online Message - ${message.conversation.phone_num}`,
rome: `Rome Online Message - ${message.conversation.phone_num}`
}),
body: message.image_path ? `Image ${message.text}` : message.text
},
@@ -157,11 +291,17 @@ exports.receive = async (req, res) => {
res.status(200).send("");
} catch (e) {
handleError(req, e, res, "RECEIVE_MESSAGE");
handleError(req, e, res, "RECEIVE_MESSAGE", logger);
}
};
const generateMediaArray = (body) => {
/**
* Generate media array from the request body
* @param body
* @param logger
* @returns {null|*[]}
*/
const generateMediaArray = (body, logger) => {
const { NumMedia } = body;
if (parseInt(NumMedia) > 0) {
const ret = [];
@@ -174,12 +314,20 @@ const generateMediaArray = (body) => {
}
};
const handleError = (req, error, res, context) => {
/**
* Handle error logging and response
* @param req
* @param error
* @param res
* @param context
* @param logger
*/
const handleError = (req, error, res, context, logger) => {
logger.log("sms-inbound-error", "ERROR", "api", null, {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body),
image_path: generateMediaArray(req.body, logger),
messagingServiceSid: req.body.MessagingServiceSid,
context,
error
@@ -187,3 +335,7 @@ const handleError = (req, error, res, context) => {
res.status(500).json({ error: error.message || "Internal Server Error" });
};
module.exports = {
receive
};

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,14 +1,21 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const client = require("../graphql-client/graphql-client").client;
const queries = require("../graphql-client/queries");
const {
UPDATE_MESSAGE_STATUS,
MARK_MESSAGES_AS_READ,
INSERT_PHONE_NUMBER_OPT_OUT,
FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID
} = require("../graphql-client/queries");
const logger = require("../utils/logger");
const { phone } = require("phone");
exports.status = async (req, res) => {
const { SmsSid, SmsStatus } = req.body;
/**
* Handle the status of an SMS message
* @param req
* @param res
* @returns {Promise<*>}
*/
const status = async (req, res) => {
const { SmsSid, SmsStatus, ErrorCode, To, MessagingServiceSid } = req.body;
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
@@ -20,18 +27,76 @@ exports.status = async (req, res) => {
return res.status(200).json({ message: "Status 'queued' disregarded." });
}
// Handle ErrorCode 21610 (Attempt to send to unsubscribed recipient) first
if (ErrorCode === "21610" && To && MessagingServiceSid) {
try {
// Step 1: Find the bodyshop by MessagingServiceSid
const bodyshopResponse = await client.request(FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, {
mssid: MessagingServiceSid,
phone: phone(To).phoneNumber // Pass the normalized phone number as required
});
const bodyshop = bodyshopResponse.bodyshops[0];
if (!bodyshop) {
logger.log("sms-opt-out-error", "ERROR", "api", null, {
msid: SmsSid,
messagingServiceSid: MessagingServiceSid,
to: To,
error: "No matching bodyshop found"
});
} else {
// Step 2: Insert into phone_number_opt_out table
const now = new Date().toISOString();
const optOutInput = {
bodyshopid: bodyshop.id,
phone_number: phone(To).phoneNumber.replace(/^\+1/, ""), // Normalize phone number (remove +1 for CA numbers)
created_at: now,
updated_at: now
};
const optOutResponse = await client.request(INSERT_PHONE_NUMBER_OPT_OUT, {
optOutInput: [optOutInput]
});
logger.log("sms-opt-out-success", "INFO", null, null, {
msid: SmsSid,
bodyshopid: bodyshop.id,
phone_number: optOutInput.phone_number,
affected_rows: optOutResponse.insert_phone_number_opt_out.affected_rows
});
// Store bodyshopid for potential use in WebSocket notification
const broadcastRoom = getBodyshopRoom(bodyshop.id);
ioRedis.to(broadcastRoom).emit("phone-number-opted-out", {
bodyshopid: bodyshop.id,
phone_number: optOutInput.phone_number
// Note: conversationId is not included yet; will be set after message lookup
});
}
} catch (error) {
logger.log("sms-opt-out-error", "ERROR", "api", null, {
msid: SmsSid,
messagingServiceSid: MessagingServiceSid,
to: To,
error: error.message,
stack: error.stack
});
// Continue processing to update message status
}
}
// Update message status in the database
const response = await client.request(queries.UPDATE_MESSAGE_STATUS, {
const response = await client.request(UPDATE_MESSAGE_STATUS, {
msid: SmsSid,
fields: { status: SmsStatus }
});
const message = response.update_messages.returning[0];
const message = response.update_messages?.returning?.[0];
if (message) {
logger.log("sms-status-update", "DEBUG", "api", null, {
msid: SmsSid,
fields: { status: SmsStatus }
status: SmsStatus
});
// Emit WebSocket event to notify the change in message status
@@ -46,26 +111,32 @@ exports.status = async (req, res) => {
type: "status-changed"
});
} else {
logger.log("sms-status-update-warning", "WARN", "api", null, {
logger.log("sms-status-update-warning", "WARN", null, null, {
msid: SmsSid,
fields: { status: SmsStatus },
warning: "No message returned from the database update."
status: SmsStatus,
warning: "No message found in database for update"
});
}
res.sendStatus(200);
} catch (error) {
} catch (err) {
logger.log("sms-status-update-error", "ERROR", "api", null, {
msid: SmsSid,
fields: { status: SmsStatus },
stack: error.stack,
message: error.message
status: SmsStatus,
error: err.message,
stack: err.stack
});
res.status(500).json({ error: "Failed to update message status." });
}
};
exports.markConversationRead = async (req, res) => {
/**
* Mark a conversation as read
* @param req
* @param res
* @returns {Promise<*>}
*/
const markConversationRead = async (req, res) => {
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
@@ -80,7 +151,7 @@ exports.markConversationRead = async (req, res) => {
}
try {
const response = await client.request(queries.MARK_MESSAGES_AS_READ, {
const response = await client.request(MARK_MESSAGES_AS_READ, {
conversationId
});
@@ -104,3 +175,8 @@ exports.markConversationRead = async (req, res) => {
res.status(500).json({ error: "Failed to mark conversation as read." });
}
};
module.exports = {
status,
markConversationRead
};

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 || "");