Merged in feature/IO-3182-Phone-Number-Consent (pull request #2324)

DO NOT MERGE JUST USING TO UNDO
This commit is contained in:
Dave Richer
2025-05-21 19:03:58 +00:00
31 changed files with 379 additions and 258 deletions

104
client/package-lock.json generated
View File

@@ -21,8 +21,8 @@
"@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.45.0",
"@sentry/react": "^9.21.0", "@sentry/react": "^9.22.0",
"@sentry/vite-plugin": "^3.4.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",
"antd": "^5.25.2", "antd": "^5.25.2",
@@ -95,7 +95,7 @@
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@eslint/js": "^9.27.0", "@eslint/js": "^9.27.0",
"@playwright/test": "^1.51.1", "@playwright/test": "^1.51.1",
"@sentry/webpack-plugin": "^3.4.0", "@sentry/webpack-plugin": "^3.5.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
@@ -4461,88 +4461,88 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sentry-internal/browser-utils": { "node_modules/@sentry-internal/browser-utils": {
"version": "9.21.0", "version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.21.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.22.0.tgz",
"integrity": "sha512-/lJ5EVUDbsVsPH/sSXwWBERVtzi4kWYeFLc+u+1zr4NrfDrGnPJ5mVS1VlHwtBmYIIWv8harLP+CReg3nDcXdw==", "integrity": "sha512-Ou1tBnVxFAIn8i9gvrWzRotNJQYiu3awNXpsFCw6qFwmiKAVPa6b13vCdolhXnrIiuR77jY1LQnKh9hXpoRzsg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/core": "9.21.0" "@sentry/core": "9.22.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/feedback": { "node_modules/@sentry-internal/feedback": {
"version": "9.21.0", "version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.21.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.22.0.tgz",
"integrity": "sha512-Z234NgcWolFpmztCh+9smC6WlO8By5t4KucHNfYSQ0xQYQCxPL5iChj3JpF4dwv+qCYXhDFLQFQbK0U3Px056g==", "integrity": "sha512-zgMVkoC61fgi41zLcSZA59vOtKxcLrKBo1ECYhPD1hxEaneNqY5fhXDwlQBw96P5l2yqkgfX6YZtSdU4ejI9yA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/core": "9.21.0" "@sentry/core": "9.22.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/replay": { "node_modules/@sentry-internal/replay": {
"version": "9.21.0", "version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.21.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.22.0.tgz",
"integrity": "sha512-7mq3Bsp8EJa3YTIYgmWfNgJdvbeaAJ6VYsqi0yxR/vNGxY3qH+PLlv+ZOEXI2U0CL6vhqFPbqmxiUOCuAjnpGg==", "integrity": "sha512-9GOycoKbrclcRXfcbNV8svbmAsOS5R4wXBQmKF4pFLkmFA/lJv9kdZSNYkRvkrxdNfbMIJXP+DV9EqTZcryXig==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/browser-utils": "9.21.0", "@sentry-internal/browser-utils": "9.22.0",
"@sentry/core": "9.21.0" "@sentry/core": "9.22.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/replay-canvas": { "node_modules/@sentry-internal/replay-canvas": {
"version": "9.21.0", "version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.21.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.22.0.tgz",
"integrity": "sha512-4tHiNil8qXphaql2YXLGA/wlm0hxaadrh7x8/KErn1iy3vJpn7t/Kka5uug7c2UWhtveS6dgGmqjSkDxM5h9bA==", "integrity": "sha512-EcG9IMSEalFe49kowBTJObWjof/iHteDwpyuAszsFDdQUYATrVUtwpwN7o52vDYWJud4arhjrQnMamIGxa79eQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/replay": "9.21.0", "@sentry-internal/replay": "9.22.0",
"@sentry/core": "9.21.0" "@sentry/core": "9.22.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry/babel-plugin-component-annotate": { "node_modules/@sentry/babel-plugin-component-annotate": {
"version": "3.4.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.5.0.tgz",
"integrity": "sha512-tSzfc3aE7m0PM0Aj7HBDet5llH9AB9oc+tBQ8AvOqUSnWodLrNCuWeQszJ7mIBovD3figgCU3h0cvI6U5cDtsg==", "integrity": "sha512-s2go8w03CDHbF9luFGtBHKJp4cSpsQzNVqgIa9Pfa4wnjipvrK6CxVT4icpLA3YO6kg5u622Yoa5GF3cJdippw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/@sentry/browser": { "node_modules/@sentry/browser": {
"version": "9.21.0", "version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.21.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.22.0.tgz",
"integrity": "sha512-NF0G104JRP2TZ2hpMHElO4bEEUdBWknKSh2d0SRyGpJFVfOQG3oRHczXWH08A5InA/lNrS9LEdodUhiFue+F3A==", "integrity": "sha512-3TeRm74dvX0JdjX0AgkQa+22iUHwHnY+Q6M05NZ+tDeCNHGK/mEBTeqquS1oQX67jWyuvYmG3VV6RJUxtG9Paw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/browser-utils": "9.21.0", "@sentry-internal/browser-utils": "9.22.0",
"@sentry-internal/feedback": "9.21.0", "@sentry-internal/feedback": "9.22.0",
"@sentry-internal/replay": "9.21.0", "@sentry-internal/replay": "9.22.0",
"@sentry-internal/replay-canvas": "9.21.0", "@sentry-internal/replay-canvas": "9.22.0",
"@sentry/core": "9.21.0" "@sentry/core": "9.22.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry/bundler-plugin-core": { "node_modules/@sentry/bundler-plugin-core": {
"version": "3.4.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.5.0.tgz",
"integrity": "sha512-X1Q41AsQ6xcT6hB4wYmBDBukndKM/inT4IsR7pdKLi7ICpX2Qq6lisamBAEPCgEvnLpazSFguaiC0uiwMKAdqw==", "integrity": "sha512-zDzPrhJqAAy2VzV4g540qAZH4qxzisstK2+NIJPZUUKztWRWUV2cMHsyUtdctYgloGkLyGpZJBE3RE6dmP/xqQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/core": "^7.18.5", "@babel/core": "^7.18.5",
"@sentry/babel-plugin-component-annotate": "3.4.0", "@sentry/babel-plugin-component-annotate": "3.5.0",
"@sentry/cli": "2.42.2", "@sentry/cli": "2.42.2",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"find-up": "^5.0.0", "find-up": "^5.0.0",
@@ -4902,22 +4902,22 @@
} }
}, },
"node_modules/@sentry/core": { "node_modules/@sentry/core": {
"version": "9.21.0", "version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.21.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.22.0.tgz",
"integrity": "sha512-K0a72Evg0fzc52Oe8R8Op5TyUMzORkk4ytt3G24lSnF4hh8NPf0m6VGkEUgQRPj27g2bF6tq9fCNsJILsf1PDA==", "integrity": "sha512-ixvtKmPF42Y6ckGUbFlB54OWI75H2gO5UYHojO6eXFpS7xO3ZGgV/QH6wb40mWK+0w5XZ0233FuU9VpsuE6mKA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry/react": { "node_modules/@sentry/react": {
"version": "9.21.0", "version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.21.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.22.0.tgz",
"integrity": "sha512-RGbyVo4fS7SX2AjEpdRXDo4C4IYIx0zQcI5bSTgySuhxL0JAxohcuSsNWpx48QkJwK/avtmlmCIPKgbvhF16TQ==", "integrity": "sha512-mI43NnioBYdG5TiXqRlhV1feZs9bnrrl+k5HOHBK7VQtymaXO0fkcsRLZTkdSgLRLMJGasZuvVhq2xK+18QyWQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/browser": "9.21.0", "@sentry/browser": "9.22.0",
"@sentry/core": "9.21.0", "@sentry/core": "9.22.0",
"hoist-non-react-statics": "^3.3.2" "hoist-non-react-statics": "^3.3.2"
}, },
"engines": { "engines": {
@@ -4928,12 +4928,12 @@
} }
}, },
"node_modules/@sentry/vite-plugin": { "node_modules/@sentry/vite-plugin": {
"version": "3.4.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-3.5.0.tgz",
"integrity": "sha512-pUFBGrKsHuc8K6A7B1wU2nx65n9aIzvTlcHX9yZ1qvjEO0cZFih0JCwu1Fcav/yrtT9RMN44L/ugu/kMBHQhjQ==", "integrity": "sha512-jUnpTdpicG8wefamw7eNo2uO+Q3KCbOAiF76xH4gfNHSW6TN2hBfOtmLu7J+ive4c0Al3+NEHz19bIPR0lkwWg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/bundler-plugin-core": "3.4.0", "@sentry/bundler-plugin-core": "3.5.0",
"unplugin": "1.0.1" "unplugin": "1.0.1"
}, },
"engines": { "engines": {
@@ -4941,13 +4941,13 @@
} }
}, },
"node_modules/@sentry/webpack-plugin": { "node_modules/@sentry/webpack-plugin": {
"version": "3.4.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-3.5.0.tgz",
"integrity": "sha512-i+nAxxniJV5ovijojjTF5n+Yj08Xk8my+vm8+oo0C0I7xcnI2gOKft6B0sJOq01CNbo85X5m/3/edL0PKoWE9w==", "integrity": "sha512-xvclj0QY2HyU7uJLzOlHSrZQBDwfnGKJxp8mmlU4L7CwmK+8xMCqlO7tYZoqE4K/wU3c2xpXql70x8qmvNMxzQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/bundler-plugin-core": "3.4.0", "@sentry/bundler-plugin-core": "3.5.0",
"unplugin": "1.0.1", "unplugin": "1.0.1",
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },

View File

@@ -20,8 +20,8 @@
"@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.45.0",
"@sentry/react": "^9.21.0", "@sentry/react": "^9.22.0",
"@sentry/vite-plugin": "^3.4.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",
"antd": "^5.25.2", "antd": "^5.25.2",
@@ -135,7 +135,7 @@
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@eslint/js": "^9.27.0", "@eslint/js": "^9.27.0",
"@playwright/test": "^1.51.1", "@playwright/test": "^1.51.1",
"@sentry/webpack-plugin": "^3.4.0", "@sentry/webpack-plugin": "^3.5.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -656,6 +656,7 @@
} }
}, },
"labels": { "labels": {
"consent_settings": "Phone Number Opt-Out List",
"2tiername": "Name => RO", "2tiername": "Name => RO",
"2tiersetup": "2 Tier Setup", "2tiersetup": "2 Tier Setup",
"2tiersource": "Source => RO", "2tiersource": "Source => RO",
@@ -2377,7 +2378,8 @@
"errors": { "errors": {
"invalidphone": "The phone number is invalid. Unable to open conversation. ", "invalidphone": "The phone number is invalid. Unable to open conversation. ",
"noattachedjobs": "No Jobs have been associated to this conversation. ", "noattachedjobs": "No Jobs have been associated to this conversation. ",
"updatinglabel": "Error updating label. {{error}}" "updatinglabel": "Error updating label. {{error}}",
"no_consent": "This phone number has not consented to receive messages."
}, },
"labels": { "labels": {
"addlabel": "Add a label to this conversation.", "addlabel": "Add a label to this conversation.",
@@ -2393,7 +2395,8 @@
"selectmedia": "Select Media", "selectmedia": "Select Media",
"sentby": "Sent by {{by}} at {{time}}", "sentby": "Sent by {{by}} at {{time}}",
"typeamessage": "Send a message...", "typeamessage": "Send a message...",
"unarchive": "Unarchive" "unarchive": "Unarchive",
"no_consent": "No Consent"
}, },
"render": { "render": {
"conversation_list": "Conversation List" "conversation_list": "Conversation List"
@@ -3862,6 +3865,14 @@
"validation": { "validation": {
"unique_vendor_name": "You must enter a unique vendor name." "unique_vendor_name": "You must enter a unique vendor name."
} }
},
"consent": {
"phone_number": "Phone Number",
"status": "Consent Status",
"updated_at": "Last Updated"
},
"settings": {
"title": "Phone Number Opt-Out List"
} }
} }
} }

View File

@@ -957,7 +957,6 @@
- enforce_conversion_category - enforce_conversion_category
- enforce_conversion_csr - enforce_conversion_csr
- enforce_referral - enforce_referral
- enforce_sms_consent
- entegral_configuration - entegral_configuration
- entegral_id - entegral_id
- features - features
@@ -1068,7 +1067,6 @@
- enforce_conversion_category - enforce_conversion_category
- enforce_conversion_csr - enforce_conversion_csr
- enforce_referral - enforce_referral
- enforce_sms_consent
- federal_tax_id - federal_tax_id
- id - id
- inhousevendorid - inhousevendorid
@@ -5864,104 +5862,12 @@
url: '{{$base_url}}/opensearch' url: '{{$base_url}}/opensearch'
version: 2 version: 2
- table: - table:
name: phone_number_consent name: phone_number_opt_out
schema: public schema: public
object_relationships: object_relationships:
- name: bodyshop - name: bodyshop
using: using:
foreign_key_constraint_on: bodyshopid foreign_key_constraint_on: bodyshopid
array_relationships:
- name: phone_number_consent_histories
using:
foreign_key_constraint_on:
column: phone_number_consent_id
table:
name: phone_number_consent_history
schema: public
insert_permissions:
- role: user
permission:
check:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
columns:
- consent_status
- phone_number
- consent_updated_at
- created_at
- updated_at
- bodyshopid
- id
comment: ""
select_permissions:
- role: user
permission:
columns:
- consent_status
- phone_number
- consent_updated_at
- created_at
- updated_at
- bodyshopid
- id
filter:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
comment: ""
update_permissions:
- role: user
permission:
columns:
- bodyshopid
- consent_status
- consent_updated_at
- created_at
- phone_number
- updated_at
filter: {}
check: null
comment: ""
- table:
name: phone_number_consent_history
schema: public
object_relationships:
- name: phone_number_consent
using:
foreign_key_constraint_on: phone_number_consent_id
select_permissions:
- role: user
permission:
columns:
- new_value
- old_value
- changed_by
- reason
- changed_at
- id
- phone_number_consent_id
filter:
phone_number_consent:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
comment: ""
- table: - table:
name: phonebook name: phonebook
schema: public schema: public

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

8
package-lock.json generated
View File

@@ -24,7 +24,7 @@
"aws4": "^1.13.2", "aws4": "^1.13.2",
"axios": "^1.8.4", "axios": "^1.8.4",
"better-queue": "^3.8.12", "better-queue": "^3.8.12",
"bullmq": "^5.52.3", "bullmq": "^5.53.0",
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
"cloudinary": "^2.6.1", "cloudinary": "^2.6.1",
"compression": "^1.8.0", "compression": "^1.8.0",
@@ -4634,9 +4634,9 @@
} }
}, },
"node_modules/bullmq": { "node_modules/bullmq": {
"version": "5.52.3", "version": "5.53.0",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.52.3.tgz", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.53.0.tgz",
"integrity": "sha512-UaVkg+uSgylYWjD6/d8TVm87SjDVZ5jKwDVh/pJACmStn71aIzOIpGazh2JrkGISgT10Q/lG2I40FhPg0KgNCQ==", "integrity": "sha512-AbzcwR+9GdgrenolOC9kApF+TkUKZpUCMiFbXgRYw9ivWhOfLCqKeajIptM7NdwhY7cpXgv+QpbweUuQZUxkyA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cron-parser": "^4.9.0", "cron-parser": "^4.9.0",

View File

@@ -31,7 +31,7 @@
"aws4": "^1.13.2", "aws4": "^1.13.2",
"axios": "^1.8.4", "axios": "^1.8.4",
"better-queue": "^3.8.12", "better-queue": "^3.8.12",
"bullmq": "^5.52.3", "bullmq": "^5.53.0",
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
"cloudinary": "^2.6.1", "cloudinary": "^2.6.1",
"compression": "^1.8.0", "compression": "^1.8.0",

View File

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

View File

@@ -7,56 +7,17 @@ const {
} = require("../graphql-client/queries"); } = require("../graphql-client/queries");
const { phone } = require("phone"); const { phone } = require("phone");
const { admin } = require("../firebase/firebase-handler"); const { admin } = require("../firebase/firebase-handler");
const logger = require("../utils/logger");
const InstanceManager = require("../utils/instanceMgr").default; const InstanceManager = require("../utils/instanceMgr").default;
/** /**
* Generate an array of media URLs from the request body * Receive SMS messages from Twilio and process them
* @param body
* @returns {null|*[]}
*/
const generateMediaArray = (body) => {
const { NumMedia } = body;
if (parseInt(NumMedia) > 0) {
const ret = [];
for (let i = 0; i < parseInt(NumMedia); i++) {
ret.push(body[`MediaUrl${i}`]);
}
return ret;
} else {
return null;
}
};
/**
* Handle errors during the message receiving process
* @param req
* @param error
* @param res
* @param context
*/
const handleError = (req, error, res, context) => {
logger.log("sms-inbound-error", "ERROR", "api", null, {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body),
messagingServiceSid: req.body.MessagingServiceSid,
context,
error
});
res.status(500).json({ error: error.message || "Internal Server Error" });
};
/**
* Receive an inbound SMS message
* @param req * @param req
* @param res * @param res
* @returns {Promise<*>} * @returns {Promise<*>}
*/ */
const receive = async (req, res) => { const receive = async (req, res) => {
const { const {
logger,
ioRedis, ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req; } = req;
@@ -65,7 +26,7 @@ const receive = async (req, res) => {
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) image_path: generateMediaArray(req.body, logger)
}; };
logger.log("sms-inbound", "DEBUG", "api", null, loggerData); logger.log("sms-inbound", "DEBUG", "api", null, loggerData);
@@ -91,7 +52,7 @@ const receive = async (req, res) => {
const bodyshop = response.bodyshops[0]; const bodyshop = response.bodyshops[0];
// Sort conversations by `updated_at` (or `created_at`) and pick the last one // Step 4: Process conversation
const sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
const existingConversation = sortedConversations.length const existingConversation = sortedConversations.length
? sortedConversations[sortedConversations.length - 1] ? sortedConversations[sortedConversations.length - 1]
@@ -102,16 +63,13 @@ const receive = async (req, res) => {
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), image_path: generateMediaArray(req.body, logger),
isoutbound: false, isoutbound: false,
userid: null // Add additional fields as necessary userid: null
}; };
if (existingConversation) { if (existingConversation) {
// Use the existing conversation
conversationid = existingConversation.id; conversationid = existingConversation.id;
// Unarchive the conversation if necessary
if (existingConversation.archived) { if (existingConversation.archived) {
await client.request(UNARCHIVE_CONVERSATION, { await client.request(UNARCHIVE_CONVERSATION, {
id: conversationid, id: conversationid,
@@ -119,7 +77,6 @@ const receive = async (req, res) => {
}); });
} }
} else { } else {
// Create a new conversation
const newConversationResponse = await client.request(CREATE_CONVERSATION, { const newConversationResponse = await client.request(CREATE_CONVERSATION, {
conversation: { conversation: {
bodyshopid: bodyshop.id, bodyshopid: bodyshop.id,
@@ -131,13 +88,12 @@ const receive = async (req, res) => {
conversationid = createdConversation.id; conversationid = createdConversation.id;
} }
// Ensure `conversationid` is added to the message
newMessage.conversationid = conversationid; newMessage.conversationid = conversationid;
// Step 3: Insert the message into the conversation // Step 5: Insert the message
const insertresp = await client.request(INSERT_MESSAGE, { const insertresp = await client.request(INSERT_MESSAGE, {
msg: newMessage, msg: newMessage,
conversationid: conversationid conversationid
}); });
const message = insertresp?.insert_messages?.returning?.[0]; const message = insertresp?.insert_messages?.returning?.[0];
@@ -147,8 +103,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 4: Notify clients through Redis // Step 6: Notify clients
const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id);
const conversationRoom = getBodyshopConversationRoom({ const conversationRoom = getBodyshopConversationRoom({
bodyshopId: conversation.bodyshop.id, bodyshopId: conversation.bodyshop.id,
conversationId: conversation.id conversationId: conversation.id
@@ -161,6 +116,8 @@ const receive = async (req, res) => {
msid: message.sid msid: message.sid
}; };
const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id);
ioRedis.to(broadcastRoom).emit("new-message-summary", { ioRedis.to(broadcastRoom).emit("new-message-summary", {
...commonPayload, ...commonPayload,
existingConversation: !!existingConversation, existingConversation: !!existingConversation,
@@ -176,7 +133,7 @@ const receive = async (req, res) => {
summary: false summary: false
}); });
// Step 5: Send FCM notification // Step 7: Send FCM notification
const fcmresp = await admin.messaging().send({ const fcmresp = await admin.messaging().send({
topic: `${message.conversation.bodyshop.imexshopid}-messaging`, topic: `${message.conversation.bodyshop.imexshopid}-messaging`,
notification: { notification: {
@@ -202,10 +159,51 @@ const receive = async (req, res) => {
res.status(200).send(""); res.status(200).send("");
} catch (e) { } catch (e) {
handleError(req, e, res, "RECEIVE_MESSAGE"); handleError(req, e, res, "RECEIVE_MESSAGE", logger);
} }
}; };
/**
* Generate media array from the request body
* @param body
* @param logger
* @returns {null|*[]}
*/
const generateMediaArray = (body, logger) => {
const { NumMedia } = body;
if (parseInt(NumMedia) > 0) {
const ret = [];
for (let i = 0; i < parseInt(NumMedia); i++) {
ret.push(body[`MediaUrl${i}`]);
}
return ret;
} else {
return null;
}
};
/**
* Handle error logging and response
* @param req
* @param error
* @param res
* @param context
* @param logger
*/
const handleError = (req, error, res, context, logger) => {
logger.log("sms-inbound-error", "ERROR", "api", null, {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body, logger),
messagingServiceSid: req.body.MessagingServiceSid,
context,
error
});
res.status(500).json({ error: error.message || "Internal Server Error" });
};
module.exports = { module.exports = {
receive receive
}; };

View File

@@ -1,7 +1,6 @@
const twilio = require("twilio"); const twilio = require("twilio");
const { phone } = require("phone"); const { phone } = require("phone");
const { INSERT_MESSAGE } = require("../graphql-client/queries"); const { INSERT_MESSAGE } = require("../graphql-client/queries");
const logger = require("../utils/logger");
const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY); const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY);
const gqlClient = require("../graphql-client/graphql-client").client; const gqlClient = require("../graphql-client/graphql-client").client;
@@ -15,6 +14,7 @@ const send = async (req, res) => {
const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body; const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body;
const { const {
ioRedis, ioRedis,
logger,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req; } = req;
@@ -26,8 +26,8 @@ const send = async (req, res) => {
conversationid, conversationid,
isoutbound: true, isoutbound: true,
userid: req.user.email, userid: req.user.email,
image: req.body.selectedMedia.length > 0, image: selectedMedia.length > 0,
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
}); });
if (!to || !messagingServiceSid || (!body && selectedMedia.length === 0) || !conversationid) { if (!to || !messagingServiceSid || (!body && selectedMedia.length === 0) || !conversationid) {
@@ -39,8 +39,8 @@ const send = async (req, res) => {
conversationid, conversationid,
isoutbound: true, isoutbound: true,
userid: req.user.email, userid: req.user.email,
image: req.body.selectedMedia.length > 0, image: selectedMedia.length > 0,
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
}); });
res.status(400).json({ success: false, message: "Missing required parameter(s)." }); res.status(400).json({ success: false, message: "Missing required parameter(s)." });
return; return;
@@ -60,8 +60,8 @@ const send = async (req, res) => {
conversationid, conversationid,
isoutbound: true, isoutbound: true,
userid: req.user.email, userid: req.user.email,
image: req.body.selectedMedia.length > 0, image: selectedMedia.length > 0,
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
}; };
try { try {