From 2e95fa25af7edb7ab6e50a4c5fb91d37b1efb1aa Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 28 May 2025 12:17:43 -0400 Subject: [PATCH 1/5] feature/IO-3182-Phone-Number-Consent - Checkpoint --- client/package-lock.json | 154 ++++++++--------- client/package.json | 4 +- .../chat-conversation-list.component.jsx | 11 +- .../chat-conversation.container.jsx | 1 + .../chat-message-list.styles.scss | 40 ++++- .../chat-messages-list/renderMessage.jsx | 34 +++- .../chat-send-message.component.jsx | 15 +- .../phone-number-consent.component.jsx | 33 ++-- client/src/graphql/conversations.queries.js | 2 + hasura/metadata/tables.yaml | 1 + .../down.sql | 4 + .../up.sql | 2 + server/sms/receive.js | 159 ++++++++++++------ 13 files changed, 303 insertions(+), 157 deletions(-) create mode 100644 hasura/migrations/1748443780878_alter_table_public_messages_add_column_is_system/down.sql create mode 100644 hasura/migrations/1748443780878_alter_table_public_messages_add_column_is_system/up.sql diff --git a/client/package-lock.json b/client/package-lock.json index fd218cc50..42436e972 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -20,8 +20,8 @@ "@firebase/messaging": "^0.12.21", "@jsreport/browser-client": "^3.1.0", "@reduxjs/toolkit": "^2.8.2", - "@sentry/cli": "^2.45.0", - "@sentry/react": "^9.22.0", + "@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", @@ -4469,50 +4469,50 @@ "license": "MIT" }, "node_modules/@sentry-internal/browser-utils": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.22.0.tgz", - "integrity": "sha512-Ou1tBnVxFAIn8i9gvrWzRotNJQYiu3awNXpsFCw6qFwmiKAVPa6b13vCdolhXnrIiuR77jY1LQnKh9hXpoRzsg==", + "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.22.0" + "@sentry/core": "9.23.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/feedback": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.22.0.tgz", - "integrity": "sha512-zgMVkoC61fgi41zLcSZA59vOtKxcLrKBo1ECYhPD1hxEaneNqY5fhXDwlQBw96P5l2yqkgfX6YZtSdU4ejI9yA==", + "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.22.0" + "@sentry/core": "9.23.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.22.0.tgz", - "integrity": "sha512-9GOycoKbrclcRXfcbNV8svbmAsOS5R4wXBQmKF4pFLkmFA/lJv9kdZSNYkRvkrxdNfbMIJXP+DV9EqTZcryXig==", + "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.22.0", - "@sentry/core": "9.22.0" + "@sentry-internal/browser-utils": "9.23.0", + "@sentry/core": "9.23.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.22.0.tgz", - "integrity": "sha512-EcG9IMSEalFe49kowBTJObWjof/iHteDwpyuAszsFDdQUYATrVUtwpwN7o52vDYWJud4arhjrQnMamIGxa79eQ==", + "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.22.0", - "@sentry/core": "9.22.0" + "@sentry-internal/replay": "9.23.0", + "@sentry/core": "9.23.0" }, "engines": { "node": ">=18" @@ -4528,16 +4528,16 @@ } }, "node_modules/@sentry/browser": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.22.0.tgz", - "integrity": "sha512-3TeRm74dvX0JdjX0AgkQa+22iUHwHnY+Q6M05NZ+tDeCNHGK/mEBTeqquS1oQX67jWyuvYmG3VV6RJUxtG9Paw==", + "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.22.0", - "@sentry-internal/feedback": "9.22.0", - "@sentry-internal/replay": "9.22.0", - "@sentry-internal/replay-canvas": "9.22.0", - "@sentry/core": "9.22.0" + "@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" @@ -4728,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": { @@ -4747,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": [ @@ -4771,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" ], @@ -4781,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" ], @@ -4798,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" @@ -4816,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" ], @@ -4833,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" ], @@ -4856,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" @@ -4873,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" ], @@ -4910,22 +4914,22 @@ } }, "node_modules/@sentry/core": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.22.0.tgz", - "integrity": "sha512-ixvtKmPF42Y6ckGUbFlB54OWI75H2gO5UYHojO6eXFpS7xO3ZGgV/QH6wb40mWK+0w5XZ0233FuU9VpsuE6mKA==", + "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.22.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.22.0.tgz", - "integrity": "sha512-mI43NnioBYdG5TiXqRlhV1feZs9bnrrl+k5HOHBK7VQtymaXO0fkcsRLZTkdSgLRLMJGasZuvVhq2xK+18QyWQ==", + "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.22.0", - "@sentry/core": "9.22.0", + "@sentry/browser": "9.23.0", + "@sentry/core": "9.23.0", "hoist-non-react-statics": "^3.3.2" }, "engines": { diff --git a/client/package.json b/client/package.json index 729aee7e2..61af4372d 100644 --- a/client/package.json +++ b/client/package.json @@ -19,8 +19,8 @@ "@firebase/messaging": "^0.12.21", "@jsreport/browser-client": "^3.1.0", "@reduxjs/toolkit": "^2.8.2", - "@sentry/cli": "^2.45.0", - "@sentry/react": "^9.22.0", + "@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", diff --git a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx index 8bf185756..e5fe4b9ca 100644 --- a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx +++ b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx @@ -1,4 +1,4 @@ -import { Badge, Card, List, Space, Tag } from "antd"; +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"; @@ -9,6 +9,7 @@ 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"; @@ -88,7 +89,13 @@ function ChatConversationListComponent({ conversationList, selectedConversation, const cardExtra = ( <> - {hasOptOutEntry && {t("messaging.labels.no_consent")}} + {hasOptOutEntry && ( + + }> + {t("messaging.labels.no_consent")} + + + )} ); diff --git a/client/src/components/chat-conversation/chat-conversation.container.jsx b/client/src/components/chat-conversation/chat-conversation.container.jsx index f951aabd1..e53ec8172 100644 --- a/client/src/components/chat-conversation/chat-conversation.container.jsx +++ b/client/src/components/chat-conversation/chat-conversation.container.jsx @@ -58,6 +58,7 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) { userid created_at read + is_system } `, data: message diff --git a/client/src/components/chat-messages-list/chat-message-list.styles.scss b/client/src/components/chat-messages-list/chat-message-list.styles.scss index 98f5ab8a4..969de0c82 100644 --- a/client/src/components/chat-messages-list/chat-message-list.styles.scss +++ b/client/src/components/chat-messages-list/chat-message-list.styles.scss @@ -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; diff --git a/client/src/components/chat-messages-list/renderMessage.jsx b/client/src/components/chat-messages-list/renderMessage.jsx index c572d77e3..2d1e1ce75 100644 --- a/client/src/components/chat-messages-list/renderMessage.jsx +++ b/client/src/components/chat-messages-list/renderMessage.jsx @@ -7,12 +7,24 @@ 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") + ) : ( + {message.created_at} + ); return ( -
+
- +
+ {isSystem && System} {/* Render images if available */} {message.image && message.image_path?.length > 0 && (
@@ -26,23 +38,31 @@ export const renderMessage = (messages, index) => {
)} {/* Render text if available */} - {message.text &&
{message.text}
} + {message.text &&
{message.text}
} + {/* Render date for system messages */} + {isSystem && ( +
+ {message.created_at} +
+ )}
- {/* Message status icons */} - {message.status && + {/* Message status icons for non-system messages */} + {!isSystem && + message.status && (message.status === "sent" || message.status === "delivered" || message.status === "failed") && (
)}
- {/* Outbound message metadata */} - {message.isoutbound && ( + {/* Outbound message metadata for non-system messages */} + {!isSystem && message.isoutbound && (
{i18n.t("messaging.labels.sentby", { by: message.userid, diff --git a/client/src/components/chat-send-message/chat-send-message.component.jsx b/client/src/components/chat-send-message/chat-send-message.component.jsx index c3e91b2b1..5b4d31d14 100644 --- a/client/src/components/chat-send-message/chat-send-message.component.jsx +++ b/client/src/components/chat-send-message/chat-send-message.component.jsx @@ -1,5 +1,5 @@ -import { LoadingOutlined, SendOutlined } from "@ant-design/icons"; -import { Alert, Input, Space, Spin } from "antd"; +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"; @@ -69,7 +69,16 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi return ( - {isOptedOut && } + {isOptedOut && ( + + } + message={t("messaging.errors.no_consent")} + type="error" + /> + + )}
{!isOptedOut && ( <> diff --git a/client/src/components/phone-number-consent/phone-number-consent.component.jsx b/client/src/components/phone-number-consent/phone-number-consent.component.jsx index c0872997d..9a9e32e51 100644 --- a/client/src/components/phone-number-consent/phone-number-consent.component.jsx +++ b/client/src/components/phone-number-consent/phone-number-consent.component.jsx @@ -1,17 +1,20 @@ import { useQuery } from "@apollo/client"; -import { Input, Space, Table, Typography } from "antd"; -import { Link } from "react-router-dom"; -import { useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; +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, SEARCH_OWNERS_BY_PHONE_NUMBERS } from "../../graphql/phone-number-opt-out.queries"; -import PhoneNumberFormatter from "../../utils/PhoneFormatter"; +import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries"; import { TimeAgoFormatter } from "../../utils/DateFormatter"; -import OwnerNameDisplay from "../owner-name-display/owner-name-display.component"; +import ChatOpenButton from "../chat-open-button/chat-open-button.component"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; -const { Paragraph } = Typography; // Destructure Paragraph from Typography +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, @@ -30,7 +33,8 @@ function PhoneNumberConsentList({ bodyshop, currentUser }) { fetchPolicy: "network-only" }); - // Prepare phone numbers for owner query + // 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]); @@ -74,16 +78,17 @@ function PhoneNumberConsentList({ bodyshop, currentUser }) { : t("consent.phone_2") : null })); - }; + };*/ const columns = [ { title: t("consent.phone_number"), dataIndex: "phone_number", - render: (text) => {text}, + render: (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) => { @@ -109,7 +114,7 @@ function PhoneNumberConsentList({ bodyshop, currentUser }) { const bName = bOwners[0] ? `${bOwners[0].ownr_fn} ${bOwners[0].ownr_ln}` : ""; return aName.localeCompare(bName); } - }, + },*/ { title: t("consent.created_at"), dataIndex: "created_at", @@ -130,7 +135,7 @@ function PhoneNumberConsentList({ bodyshop, currentUser }) { diff --git a/client/src/graphql/conversations.queries.js b/client/src/graphql/conversations.queries.js index 2379eb922..598be73dd 100644 --- a/client/src/graphql/conversations.queries.js +++ b/client/src/graphql/conversations.queries.js @@ -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 diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index c10ac7604..92d857181 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -4742,6 +4742,7 @@ - id - image - image_path + - is_system - isoutbound - msid - read diff --git a/hasura/migrations/1748443780878_alter_table_public_messages_add_column_is_system/down.sql b/hasura/migrations/1748443780878_alter_table_public_messages_add_column_is_system/down.sql new file mode 100644 index 000000000..0fbcf43ae --- /dev/null +++ b/hasura/migrations/1748443780878_alter_table_public_messages_add_column_is_system/down.sql @@ -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'; diff --git a/hasura/migrations/1748443780878_alter_table_public_messages_add_column_is_system/up.sql b/hasura/migrations/1748443780878_alter_table_public_messages_add_column_is_system/up.sql new file mode 100644 index 000000000..5c568631b --- /dev/null +++ b/hasura/migrations/1748443780878_alter_table_public_messages_add_column_is_system/up.sql @@ -0,0 +1,2 @@ +alter table "public"."messages" add column "is_system" boolean + null default 'false'; diff --git a/server/sms/receive.js b/server/sms/receive.js index a9670b76a..2df82047c 100644 --- a/server/sms/receive.js +++ b/server/sms/receive.js @@ -15,7 +15,13 @@ const InstanceManager = require("../utils/instanceMgr").default; // 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 @@ -61,7 +67,38 @@ const receive = async (req, res) => { const normalizedPhone = phone(req.body.From).phoneNumber.replace(/^\+1/, ""); // Normalize phone number (remove +1 for CA numbers) const messageText = (req.body.Body || "").trim().toUpperCase(); - // Step 2: Check for opt-in or opt-out keywords + // 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; + + if (existingConversation) { + conversationid = existingConversation.id; + if (existingConversation.archived) { + await client.request(UNARCHIVE_CONVERSATION, { + id: conversationid, + archived: false + }); + } + } else { + const newConversationResponse = await client.request(CREATE_CONVERSATION, { + conversation: { + bodyshopid: bodyshop.id, + phone_num: phone(req.body.From).phoneNumber, + archived: false + } + }); + const createdConversation = newConversationResponse.insert_conversations.returning[0]; + conversationid = createdConversation.id; + } + + // Step 3: Handle opt-in or opt-out keywords + let systemMessageText = ""; + let socketEventType = ""; + 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, { @@ -69,6 +106,7 @@ const receive = async (req, res) => { phone_number: normalizedPhone }); + // Opt In if (optInKeywords.includes(messageText)) { // Handle opt-in if (optOutCheck.phone_number_opt_out.length > 0) { @@ -85,14 +123,12 @@ const receive = async (req, res) => { affected_rows: deleteResponse.delete_phone_number_opt_out.affected_rows }); - // Emit WebSocket event to notify clients - const broadcastRoom = getBodyshopRoom(bodyshop.id); - ioRedis.to(broadcastRoom).emit("phone-number-opted-in", { - bodyshopid: bodyshop.id, - phone_number: normalizedPhone - }); + systemMessageText = systemMessageOptions.optIn; + socketEventType = "phone-number-opted-in"; } - } else if (optOutKeywords.includes(messageText)) { + } + // 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 @@ -115,59 +151,78 @@ const receive = async (req, res) => { affected_rows: insertResponse.insert_phone_number_opt_out.affected_rows }); - // Emit WebSocket event to notify clients - const broadcastRoom = getBodyshopRoom(bodyshop.id); - ioRedis.to(broadcastRoom).emit("phone-number-opted-out", { - bodyshopid: bodyshop.id, - phone_number: normalizedPhone - }); + systemMessageText = systemMessageOptions.optOut; + socketEventType = "phone-number-opted-out"; } } - // Respond immediately without processing as a regular message - res.status(200).send(""); - return; + // 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 3: 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 = { + // 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 + userid: null, + conversationid, + is_system: false }; - if (existingConversation) { - conversationid = existingConversation.id; - if (existingConversation.archived) { - await client.request(UNARCHIVE_CONVERSATION, { - id: conversationid, - archived: false - }); - } - } else { - const newConversationResponse = await client.request(CREATE_CONVERSATION, { - conversation: { - bodyshopid: bodyshop.id, - phone_num: phone(req.body.From).phoneNumber, - archived: false - } - }); - const createdConversation = newConversationResponse.insert_conversations.returning[0]; - conversationid = createdConversation.id; - } - - newMessage.conversationid = conversationid; - - // Step 4: Insert the message const insertresp = await client.request(INSERT_MESSAGE, { msg: newMessage, conversationid @@ -180,7 +235,7 @@ const receive = async (req, res) => { throw new Error("Conversation data is missing from the response."); } - // Step 5: Notify clients + // Step 5: Notify clients for original message const conversationRoom = getBodyshopConversationRoom({ bodyshopId: conversation.bodyshop.id, conversationId: conversation.id @@ -190,7 +245,7 @@ const 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); From da7e637183c076380251f004cb5960cc9474ec39 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 28 May 2025 12:26:48 -0400 Subject: [PATCH 2/5] feature/IO-3182-Phone-Number-Consent - Checkpoint --- .../owner-detail-form/owner-detail-form.container.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/components/owner-detail-form/owner-detail-form.container.jsx b/client/src/components/owner-detail-form/owner-detail-form.container.jsx index fdf2df339..d1f34b82b 100644 --- a/client/src/components/owner-detail-form/owner-detail-form.container.jsx +++ b/client/src/components/owner-detail-form/owner-detail-form.container.jsx @@ -25,14 +25,14 @@ function OwnerDetailFormContainer({ owner, refetch }) { }); console.log(result); if (result.errors) { - notification["error"]({ + notification.error({ message: t("owners.errors.deleting", { error: JSON.stringify(result.errors) }) }); setLoading(false); } else { - notification["success"]({ + notification.success({ message: t("owners.successes.delete") }); setLoading(false); @@ -47,7 +47,7 @@ function OwnerDetailFormContainer({ owner, refetch }) { }); if (!!result.errors) { - notification["error"]({ + notification.error({ message: t("owners.errors.saving", { error: JSON.stringify(result.errors) }) @@ -56,7 +56,7 @@ function OwnerDetailFormContainer({ owner, refetch }) { return; } - notification["success"]({ + notification.success({ message: t("owners.successes.save") }); From 412efb06e5da986a6a2ade84b64c03689144eaa9 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 28 May 2025 13:07:11 -0400 Subject: [PATCH 3/5] feature/IO-3182-Phone-Number-Consent - Checkpoint --- .../owner-detail-form.component.jsx | 69 +++++++--- .../owner-detail-form.container.jsx | 126 +++++++++++++----- client/src/utils/phoneOptOutService.js | 44 ++++++ 3 files changed, 187 insertions(+), 52 deletions(-) create mode 100644 client/src/utils/phoneOptOutService.js diff --git a/client/src/components/owner-detail-form/owner-detail-form.component.jsx b/client/src/components/owner-detail-form/owner-detail-form.component.jsx index a71cbc836..99bacd566 100644 --- a/client/src/components/owner-detail-form/owner-detail-form.component.jsx +++ b/client/src/components/owner-detail-form/owner-detail-form.component.jsx @@ -1,14 +1,15 @@ -import { Form, Input } 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 (
@@ -62,19 +63,55 @@ export default function OwnerDetailFormComponent({ form, loading }) { > - PhoneItemFormatterValidation(getFieldValue, "ownr_ph1")]} - > - + +
+ PhoneItemFormatterValidation(getFieldValue, "ownr_ph1")]} + > + + + {isPhone1OptedOut && ( + + + + )} +
- PhoneItemFormatterValidation(getFieldValue, "ownr_ph2")]} - > - + +
+ PhoneItemFormatterValidation(getFieldValue, "ownr_ph2")]} + > + + + {isPhone2OptedOut && ( + + + + )} +
diff --git a/client/src/components/owner-detail-form/owner-detail-form.container.jsx b/client/src/components/owner-detail-form/owner-detail-form.container.jsx index d1f34b82b..8bb0bab85 100644 --- a/client/src/components/owner-detail-form/owner-detail-form.container.jsx +++ b/client/src/components/owner-detail-form/owner-detail-form.container.jsx @@ -1,69 +1,113 @@ 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 { checkPhoneOptOutStatus } 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 && (owner?.ownr_ph1 || owner?.ownr_ph2)) { + const phoneNumbers = [owner.ownr_ph1, owner.ownr_ph2].filter(Boolean); + const optOutSet = await checkPhoneOptOutStatus(apolloClient, bodyshop.id, phoneNumbers); + setOptedOutPhones(optOutSet); + } + }; + + fetchOptOutStatus(); + }, [apolloClient, bodyshop?.id, 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) { + 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) { + 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 +116,7 @@ function OwnerDetailFormContainer({ owner, refetch }) { title={t("menus.header.owners")} extra={[ , - ]} />
- + ); } -export default OwnerDetailFormContainer; +export default connect(mapStateToProps)(OwnerDetailFormContainer); diff --git a/client/src/utils/phoneOptOutService.js b/client/src/utils/phoneOptOutService.js new file mode 100644 index 000000000..037e21c11 --- /dev/null +++ b/client/src/utils/phoneOptOutService.js @@ -0,0 +1,44 @@ +import { phone } from "phone"; +import { GET_PHONE_NUMBER_OPT_OUT } 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 of normalized opted-out phone numbers + */ +export const checkPhoneOptOutStatus = 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); + + const optedOutPhones = new Set(); + + for (const phoneNum of normalizedPhones) { + try { + const { data } = await apolloClient.query({ + query: GET_PHONE_NUMBER_OPT_OUT, + variables: { + bodyshopid: bodyshopId, + phone_number: phoneNum // Single string + }, + fetchPolicy: "network-only" + }); + + if (data?.phone_number_opt_out?.length) { + optedOutPhones.add(phoneNum); + } + } catch (error) { + console.error(`Error checking opt-out for ${phoneNum}:`, error); + } + } + + return optedOutPhones; +}; From 9466d36e69284a1cfce682e2b8318bd62be53cac Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 28 May 2025 13:17:21 -0400 Subject: [PATCH 4/5] feature/IO-3182-Phone-Number-Consent - Checkpoint --- .../owner-detail-form.container.jsx | 4 +- .../graphql/phone-number-opt-out.queries.js | 14 +++++++ client/src/utils/phoneOptOutService.js | 38 ++++++++++--------- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/client/src/components/owner-detail-form/owner-detail-form.container.jsx b/client/src/components/owner-detail-form/owner-detail-form.container.jsx index 8bb0bab85..a92913d30 100644 --- a/client/src/components/owner-detail-form/owner-detail-form.container.jsx +++ b/client/src/components/owner-detail-form/owner-detail-form.container.jsx @@ -8,7 +8,7 @@ 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 { checkPhoneOptOutStatus } from "../../utils/phoneOptOutService.js"; // 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 @@ -34,7 +34,7 @@ function OwnerDetailFormContainer({ owner, refetch, bodyshop }) { const fetchOptOutStatus = async () => { if (bodyshop?.id && (owner?.ownr_ph1 || owner?.ownr_ph2)) { const phoneNumbers = [owner.ownr_ph1, owner.ownr_ph2].filter(Boolean); - const optOutSet = await checkPhoneOptOutStatus(apolloClient, bodyshop.id, phoneNumbers); + const optOutSet = await phoneNumberOptOutService(apolloClient, bodyshop.id, phoneNumbers); setOptedOutPhones(optOutSet); } }; diff --git a/client/src/graphql/phone-number-opt-out.queries.js b/client/src/graphql/phone-number-opt-out.queries.js index 123d06744..25f982fd1 100644 --- a/client/src/graphql/phone-number-opt-out.queries.js +++ b/client/src/graphql/phone-number-opt-out.queries.js @@ -27,6 +27,20 @@ export const GET_PHONE_NUMBER_OPT_OUTS = gql` } `; +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( diff --git a/client/src/utils/phoneOptOutService.js b/client/src/utils/phoneOptOutService.js index 037e21c11..9abbef82f 100644 --- a/client/src/utils/phoneOptOutService.js +++ b/client/src/utils/phoneOptOutService.js @@ -1,5 +1,5 @@ import { phone } from "phone"; -import { GET_PHONE_NUMBER_OPT_OUT } from "../graphql/phone-number-opt-out.queries"; +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 @@ -8,7 +8,7 @@ import { GET_PHONE_NUMBER_OPT_OUT } from "../graphql/phone-number-opt-out.querie * @param {string[]} phoneNumbers - Array of phone numbers to check * @returns {Promise>} - Set of normalized opted-out phone numbers */ -export const checkPhoneOptOutStatus = async (apolloClient, bodyshopId, phoneNumbers) => { +export const phoneNumberOptOutService = async (apolloClient, bodyshopId, phoneNumbers) => { if (!apolloClient || !bodyshopId || !phoneNumbers?.length) { return new Set(); } @@ -19,25 +19,29 @@ export const checkPhoneOptOutStatus = async (apolloClient, bodyshopId, phoneNumb .map((num) => phone(num, "CA").phoneNumber?.replace(/^\+1/, "")) .filter(Boolean); + if (!normalizedPhones.length) { + return new Set(); + } + const optedOutPhones = new Set(); - for (const phoneNum of normalizedPhones) { - try { - const { data } = await apolloClient.query({ - query: GET_PHONE_NUMBER_OPT_OUT, - variables: { - bodyshopid: bodyshopId, - phone_number: phoneNum // Single string - }, - fetchPolicy: "network-only" - }); + 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) { - optedOutPhones.add(phoneNum); - } - } catch (error) { - console.error(`Error checking opt-out for ${phoneNum}:`, error); + 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; From 94bdc6c43f82357d6ab7a9e905a23da7877b96d6 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 28 May 2025 13:45:12 -0400 Subject: [PATCH 5/5] feature/IO-3182-Phone-Number-Consent - Checkpoint --- client/src/translations/en_us/common.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index fd3dd5134..178b4ceaf 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -2381,7 +2381,7 @@ "invalidphone": "The phone number is invalid. Unable to open conversation. ", "noattachedjobs": "No Jobs have been associated to this conversation. ", "updatinglabel": "Error updating label. {{error}}", - "no_consent": "This phone number has Opted-out of Messaging." + "no_consent": "This phone number has opted-out of Messaging." }, "labels": { "addlabel": "Add a label to this conversation.", @@ -2398,7 +2398,7 @@ "sentby": "Sent by {{by}} at {{time}}", "typeamessage": "Send a message...", "unarchive": "Unarchive", - "no_consent": "Opt-out" + "no_consent": "Opted-out" }, "render": { "conversation_list": "Conversation List"