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

This commit is contained in:
Dave Richer
2025-05-21 14:32:35 -04:00
parent 7bd5190bf2
commit 8ee52598e8
31 changed files with 128 additions and 991 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

@@ -8,15 +8,12 @@ import ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss"; import "./chat-affix.styles.scss";
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers"; import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { GET_PHONE_NUMBER_CONSENT } from "../../graphql/consent.queries";
export function ChatAffixContainer({ bodyshop, chatVisible }) { export function ChatAffixContainer({ bodyshop, chatVisible }) {
const { t } = useTranslation(); const { t } = useTranslation();
const client = useApolloClient(); const client = useApolloClient();
const { socket } = useSocket(); const { socket } = useSocket();
const enforceConsent = bodyshop?.enforce_sms_consent ?? false;
useEffect(() => { useEffect(() => {
if (!bodyshop || !bodyshop.messagingservicesid) return; if (!bodyshop || !bodyshop.messagingservicesid) return;
@@ -41,63 +38,11 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) {
if (socket && socket.connected) { if (socket && socket.connected) {
registerMessagingHandlers({ socket, client }); registerMessagingHandlers({ socket, client });
// Handle consent-changed events only if enforce_sms_consent is true
const handleConsentChanged = ({ bodyshopId, phone_number, consent_status, reason }) => {
if (!enforceConsent || bodyshopId !== bodyshop.id) return;
try {
const cacheData = client.readQuery({
query: GET_PHONE_NUMBER_CONSENT,
variables: { bodyshopid: bodyshopId, phone_number }
});
if (!cacheData?.phone_number_consent?.[0]) {
console.warn("No cached data for GET_PHONE_NUMBER_CONSENT:", { bodyshopId, phone_number });
return;
}
const updatedConsent = {
...cacheData.phone_number_consent[0],
consent_status,
consent_updated_at: new Date().toISOString(),
phone_number_consent_history: [
{
__typename: "phone_number_consent_history",
id: `temp-${Date.now()}`,
reason,
changed_at: new Date().toISOString(),
old_value: cacheData.phone_number_consent[0].consent_status,
new_value: consent_status,
changed_by: "system"
},
...(cacheData.phone_number_consent[0].phone_number_consent_history || [])
]
};
client.writeQuery(
{
query: GET_PHONE_NUMBER_CONSENT,
variables: { bodyshopid: bodyshopId, phone_number }
},
{
phone_number_consent: [updatedConsent]
}
);
console.log("Cache update in handleConsentChanged:", { phone_number, consent_status, updatedConsent });
} catch (error) {
console.error("Error updating consent cache in handleConsentChanged:", error.message, error.stack);
}
};
socket.on("consent-changed", handleConsentChanged);
return () => { return () => {
socket.off("consent-changed", handleConsentChanged);
unregisterMessagingHandlers({ socket }); unregisterMessagingHandlers({ socket });
}; };
} }
}, [bodyshop, socket, t, client, enforceConsent]); }, [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 { useEffect, useState, useMemo } from "react"; import React, { useEffect, 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,61 +10,35 @@ 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_CONSENTS } from "../../graphql/consent.queries";
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, bodyshop }) { function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) {
const { t } = useTranslation(); // That comma is there for a reason, do not remove it
const [, forceUpdate] = useState(false); const [, forceUpdate] = useState(false);
const enforceConsent = bodyshop?.enforce_sms_consent ?? false; // Re-render every minute
const phoneNumbers = conversationList.map((item) => phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""));
const { data: consentData, loading: consentLoading } = useQuery(GET_PHONE_NUMBER_CONSENTS, {
variables: {
bodyshopid: conversationList[0]?.bodyshopid,
phone_numbers: phoneNumbers
},
skip: !enforceConsent || !conversationList.length || !conversationList[0]?.bodyshopid,
fetchPolicy: "cache-and-network"
});
const consentMap = useMemo(() => {
const map = new Map();
consentData?.phone_number_consent?.forEach((consent) => {
map.set(consent.phone_number, consent.consent_status);
});
return map;
}, [consentData]);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
forceUpdate((prev) => !prev); forceUpdate((prev) => !prev); // Toggle state to trigger re-render
}, 60000); }, 60000); // 1 minute in milliseconds
return () => clearInterval(interval);
return () => clearInterval(interval); // Cleanup on unmount
}, []); }, []);
const sortedConversationList = useMemo(() => { // Memoize the sorted conversation list
const sortedConversationList = React.useMemo(() => {
return _.orderBy(conversationList, ["updated_at"], ["desc"]); return _.orderBy(conversationList, ["updated_at"], ["desc"]);
}, [conversationList]); }, [conversationList]);
const renderConversation = (index, t) => { const renderConversation = (index) => {
const item = sortedConversationList[index]; const item = sortedConversationList[index];
const normalizedPhone = phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
const isConsented = enforceConsent ? (consentMap.get(normalizedPhone) ?? false) : 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
@@ -86,12 +60,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
</> </>
); );
const cardExtra = ( const cardExtra = <Badge count={item.messages_aggregate.aggregate.count} />;
<>
<Badge count={item.messages_aggregate.aggregate.count} />
{enforceConsent && !isConsented && <Tag color="red">{t("messaging.labels.no_consent")}</Tag>}
</>
);
const getCardStyle = () => const getCardStyle = () =>
item.id === selectedConversation item.id === selectedConversation
@@ -104,25 +73,9 @@ 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()} variant={true} size="small" extra={cardExtra} title={cardTitle}> <Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}>
<div <div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
style={{ <div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
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>
); );
@@ -132,7 +85,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, t)} itemContent={(index) => renderConversation(index)}
style={{ height: "100%", width: "100%" }} style={{ height: "100%", width: "100%" }}
/> />
</div> </div>

View File

@@ -1,5 +1,5 @@
import { LoadingOutlined, SendOutlined } from "@ant-design/icons"; import { LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Input, Spin, Alert } 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";
@@ -12,7 +12,6 @@ import ChatMediaSelector from "../chat-media-selector/chat-media-selector.compon
import ChatPresetsComponent from "../chat-presets/chat-presets.component"; import ChatPresetsComponent from "../chat-presets/chat-presets.component";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { GET_PHONE_NUMBER_CONSENT } from "../../graphql/consent.queries"; import { GET_PHONE_NUMBER_CONSENT } from "../../graphql/consent.queries";
import AlertComponent from "../alert/alert.component";
import { phone } from "phone"; import { phone } from "phone";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
@@ -31,15 +30,13 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
const [selectedMedia, setSelectedMedia] = useState([]); const [selectedMedia, setSelectedMedia] = useState([]);
const { t } = useTranslation(); const { t } = useTranslation();
const enforceConsent = bodyshop?.enforce_sms_consent ?? false;
const normalizedPhone = phone(conversation.phone_num, "CA").phoneNumber.replace(/^\+1/, ""); const normalizedPhone = phone(conversation.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
const { data: consentData } = useQuery(GET_PHONE_NUMBER_CONSENT, { const { data: consentData } = useQuery(GET_PHONE_NUMBER_CONSENT, {
variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone }, variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone },
fetchPolicy: "cache-and-network", fetchPolicy: "cache-and-network"
skip: !enforceConsent
}); });
const isConsented = enforceConsent ? (consentData?.phone_number_consent?.[0]?.consent_status ?? false) : true;
const isConsented = consentData?.phone_number_consent?.[0]?.consent_status ?? false;
useEffect(() => { useEffect(() => {
inputArea.current.focus(); inputArea.current.focus();
@@ -72,9 +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%" }}>
{enforceConsent && !isConsented && ( {!isConsented && <Alert message={t("messaging.errors.no_consent")} type="warning" style={{ marginBottom: 8 }} />}
<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}

View File

@@ -1,6 +1,6 @@
import { useQuery, useApolloClient } from "@apollo/client"; import { useApolloClient, useQuery } from "@apollo/client";
import { Table, Switch, Input, Tooltip, Upload, Button } from "antd"; import { Input, Table } from "antd";
import { useState, useEffect } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -8,9 +8,7 @@ import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selecto
import { GET_PHONE_NUMBER_CONSENTS } from "../../graphql/consent.queries"; import { GET_PHONE_NUMBER_CONSENTS } from "../../graphql/consent.queries";
import PhoneNumberFormatter from "../../utils/PhoneFormatter"; import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { TimeAgoFormatter } from "../../utils/DateFormatter"; import { TimeAgoFormatter } from "../../utils/DateFormatter";
import { UploadOutlined } from "@ant-design/icons";
import { phone } from "phone"; import { phone } from "phone";
import axios from "axios";
import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
@@ -32,216 +30,6 @@ function PhoneNumberConsentList({ bodyshop, currentUser }) {
const client = useApolloClient(); const client = useApolloClient();
const { socket } = useSocket(); const { socket } = useSocket();
useEffect(() => {
if (!socket || !socket.connected) return;
const handleConsentChanged = ({ bodyshopId, phone_number, consent_status, reason }) => {
if (bodyshopId !== bodyshop.id) return;
try {
const cacheData = client.readQuery({
query: GET_PHONE_NUMBER_CONSENTS,
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined }
});
if (!cacheData?.phone_number_consent) {
console.warn("No cached data for GET_PHONE_NUMBER_CONSENTS in WebSocket handler");
return;
}
const updatedConsents = cacheData.phone_number_consent.map((consent) =>
consent.phone_number === phone_number
? {
...consent,
consent_status,
consent_updated_at: new Date().toISOString(),
phone_number_consent_history: [
{
__typename: "phone_number_consent_history",
id: `temp-${Date.now()}`,
reason,
changed_at: new Date().toISOString(),
old_value: consent.consent_status,
new_value: consent_status,
changed_by: currentUser.email
},
...(consent.phone_number_consent_history || [])
]
}
: consent
);
client.writeQuery(
{
query: GET_PHONE_NUMBER_CONSENTS,
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined }
},
{
phone_number_consent: updatedConsents
}
);
console.log("WebSocket cache update:", { phone_number, consent_status, updatedConsents });
} catch (error) {
console.error("Error updating consent cache (WebSocket):", error.message, error.stack);
}
};
socket.on("consent-changed", handleConsentChanged);
return () => {
socket.off("consent-changed", handleConsentChanged);
};
}, [socket, client, bodyshop.id, search, currentUser.email]);
const handleSetConsent = async (phone_number, consent_status) => {
try {
const response = await axios.post("/sms/setConsent", {
bodyshopid: bodyshop.id,
phone_number,
consent_status,
reason: "Manual override in app",
changed_by: currentUser.email
});
const updatedConsent = {
...response.data.consent,
phone_number_consent_history: response.data.consent.phone_number_consent_history.map((history) => ({
...history,
__typename: "phone_number_consent_history"
}))
};
// Update Apollo cache
const cacheData = client.readQuery({
query: GET_PHONE_NUMBER_CONSENTS,
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined }
});
let cacheUpdated = false;
if (cacheData?.phone_number_consent) {
const isPhoneNumberInCache = cacheData.phone_number_consent.some(
(consent) => consent.phone_number === phone_number
);
const updatedConsents = isPhoneNumberInCache
? cacheData.phone_number_consent.map((consent) =>
consent.phone_number === phone_number ? updatedConsent : consent
)
: [...cacheData.phone_number_consent, updatedConsent];
cacheUpdated = client.writeQuery(
{
query: GET_PHONE_NUMBER_CONSENTS,
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined }
},
{
phone_number_consent: updatedConsents
}
);
console.log("Cache update in handleSetConsent:", {
phone_number,
consent_status,
updatedConsents,
search
});
} else {
console.warn("No cached data for GET_PHONE_NUMBER_CONSENTS in handleSetConsent");
}
// Always refetch to ensure UI updates
await refetch();
notification.success({
message: t("consent.update_success")
});
} catch (error) {
notification.error({
message: t("consent.update_failed")
});
console.error("Error updating consent:", error.message, error.stack);
}
};
const handleBulkUpload = async (file) => {
const reader = new FileReader();
reader.onload = async (e) => {
const text = e.target.result;
const lines = text.split("\n").slice(1); // Skip header
const consents = lines
.filter((line) => line.trim())
.map((line) => {
const [phone_number, consent_status] = line.split(",");
return {
phone_number: phone(phone_number, "CA").phoneNumber.replace(/^\+1/, ""),
consent_status: consent_status.trim().toLowerCase() === "true"
};
});
try {
const response = await axios.post("/sms/bulkSetConsent", {
bodyshopid: bodyshop.id,
consents
});
const updatedConsents = response.data.consents.map((consent) => ({
...consent,
phone_number_consent_history: consent.phone_number_consent_history.map((history) => ({
...history,
__typename: "phone_number_consent_history"
}))
}));
// Update Apollo cache
const cacheData = client.readQuery({
query: GET_PHONE_NUMBER_CONSENTS,
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined }
});
if (cacheData?.phone_number_consent) {
const updatedConsentsMap = new Map(updatedConsents.map((consent) => [consent.phone_number, consent]));
const mergedConsents = cacheData.phone_number_consent.map((consent) =>
updatedConsentsMap.has(consent.phone_number) ? updatedConsentsMap.get(consent.phone_number) : consent
);
updatedConsents.forEach((consent) => {
if (!mergedConsents.some((c) => c.phone_number === consent.phone_number)) {
mergedConsents.push(consent);
}
});
client.writeQuery(
{
query: GET_PHONE_NUMBER_CONSENTS,
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined }
},
{
phone_number_consent: mergedConsents
}
);
console.log("Cache update in handleBulkUpload:", { updatedConsents, mergedConsents });
} else {
console.warn("No cached data for GET_PHONE_NUMBER_CONSENTS in handleBulkUpload");
}
// Refetch to ensure UI updates
await refetch();
} catch (error) {
notification.error({
message: t("consent.bulk_update_failed")
});
console.error("Bulk upload failed:", error.message, error.stack);
}
};
reader.readAsText(file);
return false;
};
if (!bodyshop?.enforce_sms_consent) return null;
const columns = [ const columns = [
{ {
title: t("consent.phone_number"), title: t("consent.phone_number"),
@@ -249,15 +37,6 @@ function PhoneNumberConsentList({ bodyshop, currentUser }) {
render: (text) => <PhoneNumberFormatter>{text}</PhoneNumberFormatter>, render: (text) => <PhoneNumberFormatter>{text}</PhoneNumberFormatter>,
sorter: (a, b) => a.phone_number.localeCompare(b.phone_number) sorter: (a, b) => a.phone_number.localeCompare(b.phone_number)
}, },
{
title: t("consent.status"),
dataIndex: "consent_status",
render: (status, record) => (
<Tooltip title={record.phone_number_consent_history?.[0]?.reason || "No audit history"}>
<Switch checked={status} onChange={(checked) => handleSetConsent(record.phone_number, checked)} />
</Tooltip>
)
},
{ {
title: t("consent.updated_at"), title: t("consent.updated_at"),
dataIndex: "consent_updated_at", dataIndex: "consent_updated_at",
@@ -272,9 +51,7 @@ function PhoneNumberConsentList({ bodyshop, currentUser }) {
onSearch={(value) => setSearch(value)} onSearch={(value) => setSearch(value)}
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
/> />
<Upload beforeUpload={handleBulkUpload} accept=".csv" showUploadList={false}>
<Button icon={<UploadOutlined />}>{t("consent.bulk_upload")}</Button>
</Upload>
<Table <Table
columns={columns} columns={columns}
dataSource={data?.phone_number_consent} dataSource={data?.phone_number_consent}

View File

@@ -1,65 +1,23 @@
import { useMutation } from "@apollo/client"; import { Typography } from "antd";
import { Switch, Typography, Tooltip, message } from "antd";
import { useTranslation } from "react-i18next"; 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 } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { updateBodyshopEnforceConsent } from "../../redux/user/user.actions";
import { UPDATE_BODYSHOP_ENFORCE_CONSENT } from "../../graphql/bodyshop.queries";
import PhoneNumberConsentList from "../phone-number-consent/phone-number-consent.component"; import PhoneNumberConsentList from "../phone-number-consent/phone-number-consent.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({});
updateBodyshopEnforceConsent: (enforce_sms_consent) => dispatch(updateBodyshopEnforceConsent(enforce_sms_consent))
});
function ShopInfoConsentComponent({ bodyshop, updateBodyshopEnforceConsent }) { function ShopInfoConsentComponent({ bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [updateEnforceConsent] = useMutation(UPDATE_BODYSHOP_ENFORCE_CONSENT, {
onError: (error) => {
message.error(t("settings.enforce_sms_consent_error"));
console.error("Error updating enforce_sms_consent:", error);
},
onCompleted: (data) => {
message.success(t("settings.enforce_sms_consent_success"));
updateBodyshopEnforceConsent(data.update_bodyshops_by_pk.enforce_sms_consent);
}
});
const enforceConsent = bodyshop?.enforce_sms_consent ?? false;
return ( return (
<div> <div>
<Typography.Title level={4}>{t("settings.title")}</Typography.Title> <Typography.Title level={4}>{t("settings.title")}</Typography.Title>
<div style={{ marginBottom: 16 }}> {<PhoneNumberConsentList bodyshop={bodyshop} />}
<Typography.Text>{t("settings.enforce_sms_consent")}</Typography.Text>
<Tooltip
title={enforceConsent ? t("settings.enforce_sms_consent_warning") : t("settings.enforce_sms_consent_enable")}
>
<Switch
checked={enforceConsent}
onChange={(checked) => {
if (!checked && enforceConsent) return; // Prevent disabling
updateEnforceConsent({
variables: { id: bodyshop.id, enforce_sms_consent: checked },
optimisticResponse: {
update_bodyshops_by_pk: {
__typename: "bodyshops",
id: bodyshop.id,
enforce_sms_consent: checked
}
}
});
}}
disabled={enforceConsent}
/>
</Tooltip>
</div>
{enforceConsent && <PhoneNumberConsentList bodyshop={bodyshop} />}
</div> </div>
); );
} }

View File

@@ -142,7 +142,6 @@ export const QUERY_BODYSHOP = gql`
intellipay_config intellipay_config
md_ro_guard md_ro_guard
notification_followers notification_followers
enforce_sms_consent
employee_teams(order_by: { name: asc }, where: { active: { _eq: true } }) { employee_teams(order_by: { name: asc }, where: { active: { _eq: true } }) {
id id
name name
@@ -364,12 +363,3 @@ export const GET_ACTIVE_EMPLOYEES_IN_SHOP = gql`
} }
} }
`; `;
export const UPDATE_BODYSHOP_ENFORCE_CONSENT = gql`
mutation UPDATE_BODYSHOP_ENFORCE_CONSENT($id: uuid!, $enforce_sms_consent: Boolean!) {
update_bodyshops_by_pk(pk_columns: { id: $id }, _set: { enforce_sms_consent: $enforce_sms_consent }) {
id
enforce_sms_consent
}
}
`;

View File

@@ -123,8 +123,3 @@ export const setImexShopId = (imexshopid) => ({
type: UserActionTypes.SET_IMEX_SHOP_ID, type: UserActionTypes.SET_IMEX_SHOP_ID,
payload: imexshopid payload: imexshopid
}); });
export const updateBodyshopEnforceConsent = (enforce_sms_consent) => ({
type: UserActionTypes.UPDATE_BODYSHOP_ENFORCE_CONSENT,
payload: enforce_sms_consent
});

View File

@@ -125,14 +125,7 @@ const userReducer = (state = INITIAL_STATE, action) => {
...state, ...state,
imexshopid: action.payload imexshopid: action.payload
}; };
case UserActionTypes.UPDATE_BODYSHOP_ENFORCE_CONSENT:
return {
...state,
bodyshop: {
...state.bodyshop,
enforce_sms_consent: action.payload
}
};
default: default:
return state; return state;
} }

View File

@@ -33,7 +33,6 @@ const UserActionTypes = {
CHECK_ACTION_CODE_FAILURE: "CHECK_ACTION_CODE_FAILURE", CHECK_ACTION_CODE_FAILURE: "CHECK_ACTION_CODE_FAILURE",
SET_CURRENT_EULA: "SET_CURRENT_EULA", SET_CURRENT_EULA: "SET_CURRENT_EULA",
EULA_ACCEPTED: "EULA_ACCEPTED", EULA_ACCEPTED: "EULA_ACCEPTED",
SET_IMEX_SHOP_ID: "SET_IMEX_SHOP_ID", SET_IMEX_SHOP_ID: "SET_IMEX_SHOP_ID"
UPDATE_BODYSHOP_ENFORCE_CONSENT: "UPDATE_BODYSHOP_ENFORCE_CONSENT"
}; };
export default UserActionTypes; export default UserActionTypes;

View File

@@ -656,6 +656,7 @@
} }
}, },
"labels": { "labels": {
"consent_settings": "Consent Settings",
"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_history
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,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

@@ -2805,7 +2805,6 @@ exports.GET_BODYSHOP_BY_ID = `
intellipay_config intellipay_config
state state
notification_followers notification_followers
enforce_sms_consent
} }
} }
`; `;
@@ -2969,132 +2968,3 @@ exports.GET_JOB_WATCHERS_MINIMAL = `
} }
} }
`; `;
// Query to get consent status for a single phone number
exports.GET_PHONE_NUMBER_CONSENT = `
query GET_PHONE_NUMBER_CONSENT($bodyshopid: uuid!, $phone_number: String!) {
phone_number_consent(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
id
bodyshopid
phone_number
consent_status
created_at
updated_at
consent_updated_at
phone_number_consent_history(order_by: { changed_at: desc }) {
id
old_value
new_value
reason
changed_at
changed_by
}
}
}
`;
// Query to get consent statuses for multiple phone numbers
exports.GET_PHONE_NUMBER_CONSENTS = `
query GET_PHONE_NUMBER_CONSENTS($bodyshopid: uuid!, $phone_numbers: [String!]) {
phone_number_consent(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _in: $phone_numbers } }) {
id
bodyshopid
phone_number
consent_status
created_at
updated_at
consent_updated_at
phone_number_consent_history(order_by: { changed_at: desc }) {
id
old_value
new_value
reason
changed_at
changed_by
}
}
}
`;
// Mutation to update enforce_sms_consent
exports.UPDATE_BODYSHOP_ENFORCE_CONSENT = `
mutation UPDATE_BODYSHOP_ENFORCE_CONSENT($id: uuid!, $enforce_sms_consent: Boolean!) {
update_bodyshops_by_pk(
pk_columns: { id: $id }
_set: { enforce_sms_consent: $enforce_sms_consent }
) {
id
enforce_sms_consent
}
}
`;
// Mutation to set consent status for a single phone number
exports.SET_PHONE_NUMBER_CONSENT = `
mutation SET_PHONE_NUMBER_CONSENT($bodyshopid: uuid!, $phone_number: String!, $consent_status: Boolean!) {
insert_phone_number_consent_one(
object: {
bodyshopid: $bodyshopid
phone_number: $phone_number
consent_status: $consent_status
consent_updated_at: "now()"
}
on_conflict: {
constraint: phone_number_consent_bodyshopid_phone_number_key
update_columns: [consent_status, consent_updated_at]
}
) {
id
bodyshopid
phone_number
consent_status
created_at
updated_at
consent_updated_at
}
}
`;
// Mutation to set consent status for multiple phone numbers
exports.BULK_SET_PHONE_NUMBER_CONSENT = `
mutation BULK_SET_PHONE_NUMBER_CONSENT($objects: [phone_number_consent_insert_input!]!) {
insert_phone_number_consent(
objects: $objects
on_conflict: {
constraint: phone_number_consent_bodyshopid_phone_number_key
update_columns: [consent_status, consent_updated_at]
}
) {
affected_rows
returning {
id
bodyshopid
phone_number
consent_status
created_at
updated_at
consent_updated_at
}
}
}
`;
// Mutation to insert multiple consent history records
exports.INSERT_PHONE_NUMBER_CONSENT_HISTORY = `
mutation INSERT_PHONE_NUMBER_CONSENT_HISTORY($objects: [phone_number_consent_history_insert_input!]!) {
insert_phone_number_consent_history(
objects: $objects
) {
affected_rows
returning {
id
phone_number_consent_id
old_value
new_value
reason
changed_at
changed_by
}
}
}
`;

View File

@@ -5,7 +5,6 @@ const { receive } = require("../sms/receive");
const { send } = require("../sms/send"); const { send } = require("../sms/send");
const { status, markConversationRead } = require("../sms/status"); const { status, markConversationRead } = require("../sms/status");
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const { setConsent, bulkSetConsent } = require("../sms/consent");
// Twilio Webhook Middleware for production // Twilio Webhook Middleware for production
const twilioWebhookMiddleware = twilio.webhook({ validate: process.env.NODE_ENV === "PRODUCTION" }); const twilioWebhookMiddleware = twilio.webhook({ validate: process.env.NODE_ENV === "PRODUCTION" });
@@ -14,7 +13,5 @@ router.post("/receive", twilioWebhookMiddleware, receive);
router.post("/send", validateFirebaseIdTokenMiddleware, send); router.post("/send", validateFirebaseIdTokenMiddleware, send);
router.post("/status", twilioWebhookMiddleware, status); router.post("/status", twilioWebhookMiddleware, status);
router.post("/markConversationRead", validateFirebaseIdTokenMiddleware, markConversationRead); router.post("/markConversationRead", validateFirebaseIdTokenMiddleware, markConversationRead);
router.post("/setConsent", validateFirebaseIdTokenMiddleware, setConsent);
router.post("/bulkSetConsent", validateFirebaseIdTokenMiddleware, bulkSetConsent);
module.exports = router; module.exports = router;

View File

@@ -1,215 +0,0 @@
const {
SET_PHONE_NUMBER_CONSENT,
BULK_SET_PHONE_NUMBER_CONSENT,
INSERT_PHONE_NUMBER_CONSENT_HISTORY
} = require("../graphql-client/queries");
const { phone } = require("phone");
const gqlClient = require("../graphql-client/graphql-client").client;
/**
* Set SMS consent for a phone number
* @param req
* @param res
* @returns {Promise<*>}
*/
const setConsent = async (req, res) => {
const { bodyshopid, phone_number, consent_status, reason, changed_by } = req.body;
const {
logger,
ioRedis,
ioHelpers: { getBodyshopRoom },
sessionUtils: { getBodyshopFromRedis }
} = req;
if (!bodyshopid || !phone_number || consent_status === undefined || !reason || !changed_by) {
logger.log("set-consent-error", "ERROR", req.user.email, null, {
type: "missing-parameters",
bodyshopid,
phone_number,
consent_status,
reason,
changed_by
});
return res.status(400).json({ success: false, message: "Missing required parameter(s)." });
}
try {
// Check enforce_sms_consent
const bodyShopData = await getBodyshopFromRedis(bodyshopid);
const enforceConsent = bodyShopData?.enforce_sms_consent ?? false;
if (!enforceConsent) {
logger.log("set-consent-error", "ERROR", req.user.email, null, {
type: "consent-not-enforced",
bodyshopid
});
return res.status(403).json({ success: false, message: "SMS consent enforcement is not enabled." });
}
const normalizedPhone = phone(phone_number, "CA").phoneNumber.replace(/^\+1/, "");
const consentResponse = await gqlClient.request(SET_PHONE_NUMBER_CONSENT, {
bodyshopid,
phone_number: normalizedPhone,
consent_status
});
const consent = consentResponse.insert_phone_number_consent_one;
// Log audit history
const historyResponse = await gqlClient.request(INSERT_PHONE_NUMBER_CONSENT_HISTORY, {
objects: [
{
phone_number_consent_id: consent.id,
old_value: null, // Not tracking old value
new_value: consent_status,
reason,
changed_by,
changed_at: "now()"
}
]
});
const history = historyResponse.insert_phone_number_consent_history.returning[0];
// Emit WebSocket event
const broadcastRoom = getBodyshopRoom(bodyshopid);
ioRedis.to(broadcastRoom).emit("consent-changed", {
bodyshopId: bodyshopid,
phone_number: normalizedPhone,
consent_status,
reason
});
logger.log("set-consent-success", "DEBUG", req.user.email, null, {
bodyshopid,
phone_number: normalizedPhone,
consent_status
});
// Return both consent and history
res.status(200).json({
success: true,
consent: {
...consent,
phone_number_consent_history: [history]
}
});
} catch (error) {
logger.log("set-consent-error", "ERROR", req.user.email, null, {
bodyshopid,
phone_number,
error: error.message,
stack: error.stack
});
res.status(500).json({ success: false, message: "Failed to update consent status." });
}
};
/**
* Bulk set SMS consent for multiple phone numbers
* @param req
* @param res
* @returns {Promise<*>}
*/
const bulkSetConsent = async (req, res) => {
const { bodyshopid, consents } = req.body; // consents: [{ phone_number, consent_status }]
const {
logger,
ioRedis,
ioHelpers: { getBodyshopRoom },
sessionUtils: { getBodyshopFromRedis }
} = req;
if (!bodyshopid || !Array.isArray(consents) || consents.length === 0) {
logger.log("bulk-set-consent-error", "ERROR", req.user.email, null, {
type: "missing-parameters",
bodyshopid,
consents
});
return res.status(400).json({ success: false, message: "Missing or invalid parameters." });
}
try {
// Check enforce_sms_consent
const bodyShopData = await getBodyshopFromRedis(bodyshopid);
const enforceConsent = bodyShopData?.enforce_sms_consent ?? false;
if (!enforceConsent) {
logger.log("bulk-set-consent-error", "ERROR", req.user.email, null, {
type: "consent-not-enforced",
bodyshopid
});
return res.status(403).json({ success: false, message: "SMS consent enforcement is not enabled." });
}
const objects = consents.map(({ phone_number, consent_status }) => ({
bodyshopid,
phone_number: phone(phone_number, "CA").phoneNumber.replace(/^\+1/, ""),
consent_status,
consent_updated_at: "now()"
}));
// Insert or update phone_number_consent records
const consentResponse = await gqlClient.request(BULK_SET_PHONE_NUMBER_CONSENT, {
objects
});
const updatedConsents = consentResponse.insert_phone_number_consent.returning;
// Log audit history
const historyObjects = updatedConsents.map((consent) => ({
phone_number_consent_id: consent.id,
old_value: null, // Not tracking old value for bulk updates
new_value: consent.consent_status,
reason: "System update via bulk upload",
changed_by: "system",
changed_at: "now()"
}));
const historyResponse = await gqlClient.request(INSERT_PHONE_NUMBER_CONSENT_HISTORY, {
objects: historyObjects
});
const history = historyResponse.insert_phone_number_consent_history.returning;
// Combine consents with their history
const consentsWithhistory = updatedConsents.map((consent, index) => ({
...consent,
phone_number_consent_history: [history[index]]
}));
// Emit WebSocket events for each consent change
const broadcastRoom = getBodyshopRoom(bodyshopid);
updatedConsents.forEach((consent) => {
ioRedis.to(broadcastRoom).emit("consent-changed", {
bodyshopId: bodyshopid,
phone_number: consent.phone_number,
consent_status: consent.consent_status,
reason: "System update via bulk upload"
});
});
logger.log("bulk-set-consent-success", "DEBUG", req.user.email, null, {
bodyshopid,
updatedCount: updatedConsents.length
});
res.status(200).json({
success: true,
updatedCount: updatedConsents.length,
consents: consentsWithhistory
});
} catch (error) {
logger.log("bulk-set-consent-error", "ERROR", req.user.email, null, {
bodyshopid,
error: error.message,
stack: error.stack
});
res.status(500).json({ success: false, message: "Failed to update consents." });
}
};
module.exports = {
setConsent,
bulkSetConsent
};

View File

@@ -3,8 +3,7 @@ const {
FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID,
UNARCHIVE_CONVERSATION, UNARCHIVE_CONVERSATION,
CREATE_CONVERSATION, CREATE_CONVERSATION,
INSERT_MESSAGE, INSERT_MESSAGE
SET_PHONE_NUMBER_CONSENT
} = 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");
@@ -17,11 +16,11 @@ const InstanceManager = require("../utils/instanceMgr").default;
* @returns {Promise<*>} * @returns {Promise<*>}
*/ */
const receive = async (req, res) => { const receive = async (req, res) => {
console.dir(req.body);
const { const {
logger, logger,
ioRedis, ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
sessionUtils: { getBodyshopFromRedis }
} = req; } = req;
const loggerData = { const loggerData = {
@@ -54,35 +53,6 @@ const receive = async (req, res) => {
const bodyshop = response.bodyshops[0]; const bodyshop = response.bodyshops[0];
// Step 2: Check enforce_sms_consent
const bodyShopData = await getBodyshopFromRedis(bodyshopid);
const enforceConsent = bodyShopData?.enforce_sms_consent ?? false;
// Step 3: Handle consent only if enforce_sms_consent is true
if (enforceConsent) {
const normalizedPhone = phone(req.body.From, "CA").phoneNumber.replace(/^\+1/, "");
const isStop = req.body.Body.toUpperCase().includes("STOP");
const consentStatus = isStop ? false : true;
const reason = isStop ? "Customer texted STOP" : "Inbound message received";
const consentResponse = await client.request(SET_PHONE_NUMBER_CONSENT, {
bodyshopid: bodyshop.id,
phone_number: normalizedPhone,
consent_status: consentStatus,
reason,
changed_by: "system"
});
// Emit WebSocket event for consent change
const broadcastRoom = getBodyshopRoom(bodyshop.id);
ioRedis.to(broadcastRoom).emit("consent-changed", {
bodyshopId: bodyshop.id,
phone_number: normalizedPhone,
consent_status: consentStatus,
reason
});
}
// Step 4: Process conversation // 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

View File

@@ -1,6 +1,6 @@
const twilio = require("twilio"); const twilio = require("twilio");
const { phone } = require("phone"); const { phone } = require("phone");
const { INSERT_MESSAGE, GET_PHONE_NUMBER_CONSENT } = require("../graphql-client/queries"); const { INSERT_MESSAGE } = require("../graphql-client/queries");
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;
@@ -42,30 +42,6 @@ const send = async (req, res) => {
} }
try { try {
// Check bodyshop's enforce_sms_consent setting
const bodyShopData = await getBodyshopFromRedis(bodyshopid);
const enforceConsent = bodyShopData?.enforce_sms_consent ?? false;
// Check consent only if enforcement is enabled
if (enforceConsent) {
const normalizedPhone = phone(to, "CA").phoneNumber.replace(/^\+1/, "");
const consentResponse = await gqlClient.request(GET_PHONE_NUMBER_CONSENT, {
bodyshopid,
phone_number: normalizedPhone
});
if (!consentResponse.phone_number_consent?.length || !consentResponse.phone_number_consent[0].consent_status) {
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
type: "no-consent",
phone_number: normalizedPhone,
conversationid
});
return res.status(403).json({
success: false,
message: "Phone number has not consented to messaging."
});
}
}
const message = await client.messages.create({ const message = await client.messages.create({
body, body,
messagingServiceSid, messagingServiceSid,

View File

@@ -9,6 +9,7 @@ const logger = require("../utils/logger");
* @returns {Promise<*>} * @returns {Promise<*>}
*/ */
const status = async (req, res) => { const status = async (req, res) => {
console.dir(req.body);
const { SmsSid, SmsStatus } = req.body; const { SmsSid, SmsStatus } = req.body;
const { const {
ioRedis, ioRedis,