feature/IO-3182-Phone-Number-Consent - Checkpoint

This commit is contained in:
Dave Richer
2025-05-28 12:17:43 -04:00
parent f6c63bbd74
commit 2e95fa25af
13 changed files with 303 additions and 157 deletions

154
client/package-lock.json generated
View File

@@ -20,8 +20,8 @@
"@firebase/messaging": "^0.12.21", "@firebase/messaging": "^0.12.21",
"@jsreport/browser-client": "^3.1.0", "@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.45.0", "@sentry/cli": "^2.46.0",
"@sentry/react": "^9.22.0", "@sentry/react": "^9.23.0",
"@sentry/vite-plugin": "^3.5.0", "@sentry/vite-plugin": "^3.5.0",
"@splitsoftware/splitio-react": "^2.1.1", "@splitsoftware/splitio-react": "^2.1.1",
"@tanem/react-nprogress": "^5.0.53", "@tanem/react-nprogress": "^5.0.53",
@@ -4469,50 +4469,50 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sentry-internal/browser-utils": { "node_modules/@sentry-internal/browser-utils": {
"version": "9.22.0", "version": "9.23.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.22.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.23.0.tgz",
"integrity": "sha512-Ou1tBnVxFAIn8i9gvrWzRotNJQYiu3awNXpsFCw6qFwmiKAVPa6b13vCdolhXnrIiuR77jY1LQnKh9hXpoRzsg==", "integrity": "sha512-hyN2Q6mh7ggw8sDVHeRyWz5LR6gjvf8zHSzQnMaF7QkeSyaeGM/SVSL4ODwqR9TRH7U2ku6nZFMbKhaBPV+Hfg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/core": "9.22.0" "@sentry/core": "9.23.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/feedback": { "node_modules/@sentry-internal/feedback": {
"version": "9.22.0", "version": "9.23.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.22.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.23.0.tgz",
"integrity": "sha512-zgMVkoC61fgi41zLcSZA59vOtKxcLrKBo1ECYhPD1hxEaneNqY5fhXDwlQBw96P5l2yqkgfX6YZtSdU4ejI9yA==", "integrity": "sha512-Xf+KqV69TBiPo1gk2EsU6O/dumuTMxWOF52uVWJddQYI3pQTU5DqSeoZ5AY76bIIhV9n6AEFDGqNPXmuj4Acrw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/core": "9.22.0" "@sentry/core": "9.23.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/replay": { "node_modules/@sentry-internal/replay": {
"version": "9.22.0", "version": "9.23.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.22.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.23.0.tgz",
"integrity": "sha512-9GOycoKbrclcRXfcbNV8svbmAsOS5R4wXBQmKF4pFLkmFA/lJv9kdZSNYkRvkrxdNfbMIJXP+DV9EqTZcryXig==", "integrity": "sha512-0/q15tvSboaK7/05BFQhs71bqgHKejJoDJgXmH0lySqgsRh/S18867ZxQNiuYhuVt337h07u1QaCyjnNJKHfuA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/browser-utils": "9.22.0", "@sentry-internal/browser-utils": "9.23.0",
"@sentry/core": "9.22.0" "@sentry/core": "9.23.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/replay-canvas": { "node_modules/@sentry-internal/replay-canvas": {
"version": "9.22.0", "version": "9.23.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.22.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.23.0.tgz",
"integrity": "sha512-EcG9IMSEalFe49kowBTJObWjof/iHteDwpyuAszsFDdQUYATrVUtwpwN7o52vDYWJud4arhjrQnMamIGxa79eQ==", "integrity": "sha512-cYlw5svJjyPequm0PJjFGLpee86L1NieONEHlujOXkIG6IEriiorMm+8bNpGsHRuyvg41B+4P/YmcQAGtEGxXg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/replay": "9.22.0", "@sentry-internal/replay": "9.23.0",
"@sentry/core": "9.22.0" "@sentry/core": "9.23.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -4528,16 +4528,16 @@
} }
}, },
"node_modules/@sentry/browser": { "node_modules/@sentry/browser": {
"version": "9.22.0", "version": "9.23.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.22.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.23.0.tgz",
"integrity": "sha512-3TeRm74dvX0JdjX0AgkQa+22iUHwHnY+Q6M05NZ+tDeCNHGK/mEBTeqquS1oQX67jWyuvYmG3VV6RJUxtG9Paw==", "integrity": "sha512-QRkNxWys8e088260vByztoTsEVZ0W6v/XnZfKT6wkPPGn0aFeOrg/xjgxfI8D5huqZCxT28Cf23akOOly4FXjg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/browser-utils": "9.22.0", "@sentry-internal/browser-utils": "9.23.0",
"@sentry-internal/feedback": "9.22.0", "@sentry-internal/feedback": "9.23.0",
"@sentry-internal/replay": "9.22.0", "@sentry-internal/replay": "9.23.0",
"@sentry-internal/replay-canvas": "9.22.0", "@sentry-internal/replay-canvas": "9.23.0",
"@sentry/core": "9.22.0" "@sentry/core": "9.23.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -4728,9 +4728,9 @@
} }
}, },
"node_modules/@sentry/cli": { "node_modules/@sentry/cli": {
"version": "2.45.0", "version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.45.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.46.0.tgz",
"integrity": "sha512-4sWu7zgzgHAjIxIjXUA/66qgeEf5ZOlloO+/JaGD5qXNSW0G7KMTR6iYjReNKMgdBCTH6bUUt9qiuA+Ex9Masw==", "integrity": "sha512-nqoPl7UCr446QFkylrsRrUXF51x8Z9dGquyf4jaQU+OzbOJMqclnYEvU6iwbwvaw3tu/2DnoZE/Og+Nq1h63sA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@@ -4747,20 +4747,20 @@
"node": ">= 10" "node": ">= 10"
}, },
"optionalDependencies": { "optionalDependencies": {
"@sentry/cli-darwin": "2.45.0", "@sentry/cli-darwin": "2.46.0",
"@sentry/cli-linux-arm": "2.45.0", "@sentry/cli-linux-arm": "2.46.0",
"@sentry/cli-linux-arm64": "2.45.0", "@sentry/cli-linux-arm64": "2.46.0",
"@sentry/cli-linux-i686": "2.45.0", "@sentry/cli-linux-i686": "2.46.0",
"@sentry/cli-linux-x64": "2.45.0", "@sentry/cli-linux-x64": "2.46.0",
"@sentry/cli-win32-arm64": "2.45.0", "@sentry/cli-win32-arm64": "2.46.0",
"@sentry/cli-win32-i686": "2.45.0", "@sentry/cli-win32-i686": "2.46.0",
"@sentry/cli-win32-x64": "2.45.0" "@sentry/cli-win32-x64": "2.46.0"
} }
}, },
"node_modules/@sentry/cli-darwin": { "node_modules/@sentry/cli-darwin": {
"version": "2.45.0", "version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.45.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.46.0.tgz",
"integrity": "sha512-p4Uxfv/L2fQdP3/wYnKVVz9gzZJf/1Xp9D+6raax/3Bu5y87yHYUqcdt98y/VAXQD4ofp2QgmhGUVPofvQNZmg==", "integrity": "sha512-5Ll+e5KAdIk9OYiZO8aifMBRNWmNyPjSqdjaHlBC1Qfh7pE3b1zyzoHlsUazG0bv0sNrSGea8e7kF5wIO1hvyg==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4771,9 +4771,9 @@
} }
}, },
"node_modules/@sentry/cli-linux-arm": { "node_modules/@sentry/cli-linux-arm": {
"version": "2.45.0", "version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.45.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.46.0.tgz",
"integrity": "sha512-6sEskFLlFKJ+e0MOYgIclBTUX5jYMyYhHIxXahEkI/4vx6JO0uvpyRAkUJRpJkRh/lPog0FM+tbP3so+VxB2qQ==", "integrity": "sha512-WRrLNq/TEX/TNJkGqq6Ad0tGyapd5dwlxtsPbVBrIdryuL1mA7VCBoaHBr3kcwJLsgBHFH0lmkMee2ubNZZdkg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -4781,16 +4781,17 @@
"optional": true, "optional": true,
"os": [ "os": [
"linux", "linux",
"freebsd" "freebsd",
"android"
], ],
"engines": { "engines": {
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@sentry/cli-linux-arm64": { "node_modules/@sentry/cli-linux-arm64": {
"version": "2.45.0", "version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.45.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.46.0.tgz",
"integrity": "sha512-gUcLoEjzg7AIc4QQGEZwRHri+EHf3Gcms9zAR1VHiNF3/C/jL4WeDPJF2YiWAQt6EtH84tHiyhw1Ab/R8XFClg==", "integrity": "sha512-OEJN8yAjI9y5B4telyqzu27Hi3+S4T8VxZCqJz1+z2Mp0Q/MZ622AahVPpcrVq/5bxrnlZR16+lKh8L1QwNFPg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4798,16 +4799,17 @@
"optional": true, "optional": true,
"os": [ "os": [
"linux", "linux",
"freebsd" "freebsd",
"android"
], ],
"engines": { "engines": {
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@sentry/cli-linux-i686": { "node_modules/@sentry/cli-linux-i686": {
"version": "2.45.0", "version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.45.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.46.0.tgz",
"integrity": "sha512-VmmOaEAzSW23YdGNdy/+oQjCNAMY+HmOGA77A25/ep/9AV7PQB6FI7xO5Y1PVvlkxZFJ23e373njSsEeg4uDZw==", "integrity": "sha512-xko3/BVa4LX8EmRxVOCipV+PwfcK5Xs8lP6lgF+7NeuAHMNL4DqF6iV9rrN8gkGUHCUI9RXSve37uuZnFy55+Q==",
"cpu": [ "cpu": [
"x86", "x86",
"ia32" "ia32"
@@ -4816,16 +4818,17 @@
"optional": true, "optional": true,
"os": [ "os": [
"linux", "linux",
"freebsd" "freebsd",
"android"
], ],
"engines": { "engines": {
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@sentry/cli-linux-x64": { "node_modules/@sentry/cli-linux-x64": {
"version": "2.45.0", "version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.45.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.46.0.tgz",
"integrity": "sha512-a0Oj68mrb25a0WjX/ShZ6AAd4PPiuLcgyzQr7bl2+DvYxIOajwkGbR+CZFEhOVZcfhTnixKy/qIXEzApEPHPQg==", "integrity": "sha512-hJ1g5UEboYcOuRia96LxjJ0jhnmk8EWLDvlGnXLnYHkwy3ree/L7sNgdp/QsY8Z4j2PGO5f22Va+UDhSjhzlfQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -4833,16 +4836,17 @@
"optional": true, "optional": true,
"os": [ "os": [
"linux", "linux",
"freebsd" "freebsd",
"android"
], ],
"engines": { "engines": {
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@sentry/cli-win32-arm64": { "node_modules/@sentry/cli-win32-arm64": {
"version": "2.45.0", "version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.45.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.46.0.tgz",
"integrity": "sha512-vn+CwS4p+52pQSLNPoi20ZOrQmv01ZgAmuMnjkh1oUZfTyBAwWLrAh6Cy4cztcN8DfL5dOWKQBo8DBKURE4ttg==", "integrity": "sha512-mN7cpPoCv2VExFRGHt+IoK11yx4pM4ADZQGEso5BAUZ5duViXB2WrAXCLd8DrwMnP0OE978a7N8OtzsFqjkbNA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4856,9 +4860,9 @@
} }
}, },
"node_modules/@sentry/cli-win32-i686": { "node_modules/@sentry/cli-win32-i686": {
"version": "2.45.0", "version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.45.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.46.0.tgz",
"integrity": "sha512-8mMoDdlwxtcdNIMtteMK7dbi7054jak8wKSHJ5yzMw8UmWxC5thc/gXBc1uPduiaI56VjoJV+phWHBKCD+6I4w==", "integrity": "sha512-6F73AUE3lm71BISUO19OmlnkFD5WVe4/wA1YivtLZTc1RU3eUYJLYxhDfaH3P77+ycDppQ2yCgemLRaA4A8mNQ==",
"cpu": [ "cpu": [
"x86", "x86",
"ia32" "ia32"
@@ -4873,9 +4877,9 @@
} }
}, },
"node_modules/@sentry/cli-win32-x64": { "node_modules/@sentry/cli-win32-x64": {
"version": "2.45.0", "version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.45.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.46.0.tgz",
"integrity": "sha512-ZvK9cIqFaq7vZ0jkHJ/xh5au6902Dr+AUxSk6L6vCL7JCe2p93KGL/4d8VFB5PD/P7Y9b+105G/e0QIFKzpeOw==", "integrity": "sha512-yuGVcfepnNL84LGA0GjHzdMIcOzMe0bjPhq/rwPsPN+zu11N+nPR2wV2Bum4U0eQdqYH3iAlMdL5/BEQfuLJww==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -4910,22 +4914,22 @@
} }
}, },
"node_modules/@sentry/core": { "node_modules/@sentry/core": {
"version": "9.22.0", "version": "9.23.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.22.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.23.0.tgz",
"integrity": "sha512-ixvtKmPF42Y6ckGUbFlB54OWI75H2gO5UYHojO6eXFpS7xO3ZGgV/QH6wb40mWK+0w5XZ0233FuU9VpsuE6mKA==", "integrity": "sha512-9846pn/BvASGgl7WsnKY4xry98WreP9ToeLfCQTQOf+pNfD/qNPn1/0xPInGni3LVMAXRtfHHMPm2Ghz255N7A==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry/react": { "node_modules/@sentry/react": {
"version": "9.22.0", "version": "9.23.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.22.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.23.0.tgz",
"integrity": "sha512-mI43NnioBYdG5TiXqRlhV1feZs9bnrrl+k5HOHBK7VQtymaXO0fkcsRLZTkdSgLRLMJGasZuvVhq2xK+18QyWQ==", "integrity": "sha512-2J/oOx8jd7Jr2koYIe5IcJyStHBXpjkQnxawo54Zyyvzc96MftyM2Dv5TeYdz7fChU1NIXw7BVbEpkQ9XEQlqg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/browser": "9.22.0", "@sentry/browser": "9.23.0",
"@sentry/core": "9.22.0", "@sentry/core": "9.23.0",
"hoist-non-react-statics": "^3.3.2" "hoist-non-react-statics": "^3.3.2"
}, },
"engines": { "engines": {

View File

@@ -19,8 +19,8 @@
"@firebase/messaging": "^0.12.21", "@firebase/messaging": "^0.12.21",
"@jsreport/browser-client": "^3.1.0", "@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.45.0", "@sentry/cli": "^2.46.0",
"@sentry/react": "^9.22.0", "@sentry/react": "^9.23.0",
"@sentry/vite-plugin": "^3.5.0", "@sentry/vite-plugin": "^3.5.0",
"@splitsoftware/splitio-react": "^2.1.1", "@splitsoftware/splitio-react": "^2.1.1",
"@tanem/react-nprogress": "^5.0.53", "@tanem/react-nprogress": "^5.0.53",

View File

@@ -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 { useEffect, useMemo, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Virtuoso } from "react-virtuoso"; import { Virtuoso } from "react-virtuoso";
@@ -9,6 +9,7 @@ import { TimeAgoFormatter } from "../../utils/DateFormatter";
import PhoneFormatter from "../../utils/PhoneFormatter"; import PhoneFormatter from "../../utils/PhoneFormatter";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component"; import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import _ from "lodash"; import _ from "lodash";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import "./chat-conversation-list.styles.scss"; import "./chat-conversation-list.styles.scss";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries.js"; import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries.js";
@@ -88,7 +89,13 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
const cardExtra = ( const cardExtra = (
<> <>
<Badge count={item.messages_aggregate.aggregate.count} /> <Badge count={item.messages_aggregate.aggregate.count} />
{hasOptOutEntry && <Tag color="red">{t("messaging.labels.no_consent")}</Tag>} {hasOptOutEntry && (
<Tooltip title={t("consent.text_body")}>
<Tag color="red" icon={<ExclamationCircleOutlined />}>
{t("messaging.labels.no_consent")}
</Tag>
</Tooltip>
)}
</> </>
); );

View File

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

View File

@@ -4,13 +4,16 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
.archive-button { .archive-button {
height: 20px; height: 20px;
border-radius: 4px; border-radius: 4px;
} }
.chat-title { .chat-title {
margin-bottom: 5px; margin-bottom: 5px;
} }
.messages { .messages {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -37,11 +40,13 @@
gap: 8px; gap: 8px;
} }
} }
.chat-send-message-button{
.chat-send-message-button {
margin: 0.3rem; margin: 0.3rem;
padding-left: 0.5rem; padding-left: 0.5rem;
} }
.message-icon { .message-icon {
position: absolute; position: absolute;
bottom: 0.1rem; 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 { .virtuoso-container {
flex: 1; flex: 1;
overflow: auto; overflow: auto;

View File

@@ -7,12 +7,24 @@ import { DateTimeFormatter } from "../../utils/DateFormatter";
export const renderMessage = (messages, index) => { export const renderMessage = (messages, index) => {
const message = 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 ( return (
<div key={index} className={`${message.isoutbound ? "mine messages" : "yours messages"}`}> <div key={index} className={messageClass}>
<div className="message msgmargin"> <div className="message msgmargin">
<Tooltip title={DateTimeFormatter({ children: message.created_at })}> <Tooltip title={tooltipTitle}>
<div> <div>
{isSystem && <span className="system-label">System</span>}
{/* Render images if available */} {/* Render images if available */}
{message.image && message.image_path?.length > 0 && ( {message.image && message.image_path?.length > 0 && (
<div className="message-images"> <div className="message-images">
@@ -26,23 +38,31 @@ export const renderMessage = (messages, index) => {
</div> </div>
)} )}
{/* Render text if available */} {/* 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> </div>
</Tooltip> </Tooltip>
{/* Message status icons */} {/* Message status icons for non-system messages */}
{message.status && {!isSystem &&
message.status &&
(message.status === "sent" || message.status === "delivered" || message.status === "failed") && ( (message.status === "sent" || message.status === "delivered" || message.status === "failed") && (
<div className="message-status"> <div className="message-status">
<Icon <Icon
component={message.status === "sent" ? MdDone : message.status === "delivered" ? MdDoneAll : MdClose} component={message.status === "sent" ? MdDone : message.status === "delivered" ? MdDoneAll : MdClose}
className="message-icon" className="message-icon"
style={message.status === "failed" ? { color: "#ff0000" } : undefined}
/> />
</div> </div>
)} )}
</div> </div>
{/* Outbound message metadata */} {/* Outbound message metadata for non-system messages */}
{message.isoutbound && ( {!isSystem && message.isoutbound && (
<div style={{ fontSize: 10 }}> <div style={{ fontSize: 10 }}>
{i18n.t("messaging.labels.sentby", { {i18n.t("messaging.labels.sentby", {
by: message.userid, by: message.userid,

View File

@@ -1,5 +1,5 @@
import { LoadingOutlined, SendOutlined } from "@ant-design/icons"; import { ExclamationCircleOutlined, LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Alert, Input, Space, Spin } from "antd"; import { Alert, Input, Space, Spin, Tooltip } from "antd";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
@@ -69,7 +69,16 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
return ( return (
<Space direction="vertical" style={{ width: "100%" }} size="middle"> <Space direction="vertical" style={{ width: "100%" }} size="middle">
{isOptedOut && <Alert message={t("messaging.errors.no_consent")} type="error" />} {isOptedOut && (
<Tooltip title={t("consent.text_body")}>
<Alert
showIcon={true}
icon={<ExclamationCircleOutlined />}
message={t("messaging.errors.no_consent")}
type="error"
/>
</Tooltip>
)}
<div className="imex-flex-row" style={{ width: "100%" }}> <div className="imex-flex-row" style={{ width: "100%" }}>
{!isOptedOut && ( {!isOptedOut && (
<> <>

View File

@@ -1,17 +1,20 @@
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { Input, Space, Table, Typography } from "antd"; import { Input, Table, Typography } from "antd";
import { Link } from "react-router-dom";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; 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 { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { TimeAgoFormatter } from "../../utils/DateFormatter"; 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({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -30,7 +33,8 @@ function PhoneNumberConsentList({ bodyshop, currentUser }) {
fetchPolicy: "network-only" 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(() => { const phoneNumbers = useMemo(() => {
return optOutData?.phone_number_opt_out?.map((item) => item.phone_number) || []; return optOutData?.phone_number_opt_out?.map((item) => item.phone_number) || [];
}, [optOutData?.phone_number_opt_out]); }, [optOutData?.phone_number_opt_out]);
@@ -74,16 +78,17 @@ function PhoneNumberConsentList({ bodyshop, currentUser }) {
: t("consent.phone_2") : t("consent.phone_2")
: null : null
})); }));
}; };*/
const columns = [ const columns = [
{ {
title: t("consent.phone_number"), title: t("consent.phone_number"),
dataIndex: "phone_number", dataIndex: "phone_number",
render: (text) => <PhoneNumberFormatter>{text}</PhoneNumberFormatter>, render: (text) => <ChatOpenButton phone={text} />,
sorter: (a, b) => a.phone_number.localeCompare(b.phone_number) sorter: (a, b) => a.phone_number.localeCompare(b.phone_number)
}, },
{ // Commented out Associated Owners section for now
/*{
title: t("consent.associated_owners"), title: t("consent.associated_owners"),
dataIndex: "phone_number", dataIndex: "phone_number",
render: (phoneNumber) => { render: (phoneNumber) => {
@@ -109,7 +114,7 @@ function PhoneNumberConsentList({ bodyshop, currentUser }) {
const bName = bOwners[0] ? `${bOwners[0].ownr_fn} ${bOwners[0].ownr_ln}` : ""; const bName = bOwners[0] ? `${bOwners[0].ownr_fn} ${bOwners[0].ownr_ln}` : "";
return aName.localeCompare(bName); return aName.localeCompare(bName);
} }
}, },*/
{ {
title: t("consent.created_at"), title: t("consent.created_at"),
dataIndex: "created_at", dataIndex: "created_at",
@@ -130,7 +135,7 @@ function PhoneNumberConsentList({ bodyshop, currentUser }) {
<Table <Table
columns={columns} columns={columns}
dataSource={optOutData?.phone_number_opt_out} dataSource={optOutData?.phone_number_opt_out}
loading={optOutLoading || ownersLoading} loading={optOutLoading /* || ownersLoading*/}
rowKey="id" rowKey="id"
style={{ marginTop: 16 }} style={{ marginTop: 16 }}
/> />

View File

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

View File

@@ -4742,6 +4742,7 @@
- id - id
- image - image
- image_path - image_path
- is_system
- isoutbound - isoutbound
- msid - msid
- read - read

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

@@ -15,7 +15,13 @@ const InstanceManager = require("../utils/instanceMgr").default;
// Note: When we handle different languages, we might need to adjust these keywords accordingly. // Note: When we handle different languages, we might need to adjust these keywords accordingly.
const optInKeywords = ["START", "YES", "UNSTOP"]; const optInKeywords = ["START", "YES", "UNSTOP"];
const optOutKeywords = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT", "REVOKE", "OPTOUT"]; 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 * Receive SMS messages from Twilio and process them
* @param req * @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 normalizedPhone = phone(req.body.From).phoneNumber.replace(/^\+1/, ""); // Normalize phone number (remove +1 for CA numbers)
const messageText = (req.body.Body || "").trim().toUpperCase(); 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)) { if (optInKeywords.includes(messageText) || optOutKeywords.includes(messageText)) {
// Check if the phone number is in phone_number_opt_out // Check if the phone number is in phone_number_opt_out
const optOutCheck = await client.request(CHECK_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 phone_number: normalizedPhone
}); });
// Opt In
if (optInKeywords.includes(messageText)) { if (optInKeywords.includes(messageText)) {
// Handle opt-in // Handle opt-in
if (optOutCheck.phone_number_opt_out.length > 0) { 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 affected_rows: deleteResponse.delete_phone_number_opt_out.affected_rows
}); });
// Emit WebSocket event to notify clients systemMessageText = systemMessageOptions.optIn;
const broadcastRoom = getBodyshopRoom(bodyshop.id); socketEventType = "phone-number-opted-in";
ioRedis.to(broadcastRoom).emit("phone-number-opted-in", {
bodyshopid: bodyshop.id,
phone_number: normalizedPhone
});
} }
} else if (optOutKeywords.includes(messageText)) { }
// Opt Out
else if (optOutKeywords.includes(messageText)) {
// Handle opt-out // Handle opt-out
if (optOutCheck.phone_number_opt_out.length === 0) { if (optOutCheck.phone_number_opt_out.length === 0) {
// Phone number is not opted out; insert a new record // 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 affected_rows: insertResponse.insert_phone_number_opt_out.affected_rows
}); });
// Emit WebSocket event to notify clients systemMessageText = systemMessageOptions.optOut;
const broadcastRoom = getBodyshopRoom(bodyshop.id); socketEventType = "phone-number-opted-out";
ioRedis.to(broadcastRoom).emit("phone-number-opted-out", {
bodyshopid: bodyshop.id,
phone_number: normalizedPhone
});
} }
} }
// Respond immediately without processing as a regular message // Insert system message if an opt-in or opt-out action was taken
res.status(200).send(""); if (systemMessageText) {
return; 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 // Step 4: Insert the original message
const sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const newMessage = {
const existingConversation = sortedConversations.length
? sortedConversations[sortedConversations.length - 1]
: null;
let conversationid;
let newMessage = {
msid: req.body.SmsMessageSid, msid: req.body.SmsMessageSid,
text: req.body.Body, text: req.body.Body,
image: !!req.body.MediaUrl0, image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body, logger), image_path: generateMediaArray(req.body, logger),
isoutbound: false, 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, { const insertresp = await client.request(INSERT_MESSAGE, {
msg: newMessage, msg: newMessage,
conversationid conversationid
@@ -180,7 +235,7 @@ const receive = async (req, res) => {
throw new Error("Conversation data is missing from the response."); throw new Error("Conversation data is missing from the response.");
} }
// Step 5: Notify clients // Step 5: Notify clients for original message
const conversationRoom = getBodyshopConversationRoom({ const conversationRoom = getBodyshopConversationRoom({
bodyshopId: conversation.bodyshop.id, bodyshopId: conversation.bodyshop.id,
conversationId: conversation.id conversationId: conversation.id
@@ -190,7 +245,7 @@ const receive = async (req, res) => {
isoutbound: false, isoutbound: false,
conversationId: conversation.id, conversationId: conversation.id,
updated_at: message.updated_at, updated_at: message.updated_at,
msid: message.sid msid: message.msid
}; };
const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id); const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id);