From 2e95fa25af7edb7ab6e50a4c5fb91d37b1efb1aa Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 28 May 2025 12:17:43 -0400 Subject: [PATCH] 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);