From 83860152a9faedc32fc84b425dd0b4bf6a014418 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 20 May 2025 16:04:36 -0400 Subject: [PATCH 1/5] feature/IO-3182-Phone-Number-Consent - Checkpoint --- .../chat-affix/chat-affix.container.jsx | 56 +++++++- .../chat-conversation-list.component.jsx | 51 +++++-- .../chat-conversation-list.styles.scss | 2 +- .../chat-media-selector.component.jsx | 8 +- .../chat-send-message.component.jsx | 27 +++- .../phone-number-consent.component.jsx | 131 ++++++++++++++++++ .../shop-info/shop-info.consent.component.jsx | 51 +++++++ client/src/graphql/bodyshop.queries.js | 10 ++ client/src/graphql/consent.queries.js | 90 ++++++++++++ client/src/pages/shop/shop.page.component.jsx | 10 +- server/graphql-client/queries.js | 101 ++++++++++++++ server/sms/receive.js | 46 ++++-- 12 files changed, 540 insertions(+), 43 deletions(-) create mode 100644 client/src/components/phone-number-consent/phone-number-consent.component.jsx create mode 100644 client/src/components/shop-info/shop-info.consent.component.jsx create mode 100644 client/src/graphql/consent.queries.js diff --git a/client/src/components/chat-affix/chat-affix.container.jsx b/client/src/components/chat-affix/chat-affix.container.jsx index 9885b8551..cf8afce3b 100644 --- a/client/src/components/chat-affix/chat-affix.container.jsx +++ b/client/src/components/chat-affix/chat-affix.container.jsx @@ -8,6 +8,7 @@ import ChatPopupComponent from "../chat-popup/chat-popup.component"; import "./chat-affix.styles.scss"; import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers"; import { useSocket } from "../../contexts/SocketIO/useSocket.js"; +import { GET_PHONE_NUMBER_CONSENT } from "../../graphql/consent.queries.js"; export function ChatAffixContainer({ bodyshop, chatVisible }) { const { t } = useTranslation(); @@ -34,16 +35,59 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) { SubscribeToTopicForFCMNotification(); - //Register WS handlers + // Register WebSocket handlers if (socket && socket.connected) { registerMessagingHandlers({ socket, client }); - } - return () => { - if (socket && socket.connected) { + // Handle consent-changed events + const handleConsentChanged = ({ bodyshopId, phone_number, consent_status }) => { + try { + client.cache.writeQuery( + { + query: GET_PHONE_NUMBER_CONSENT, + variables: { bodyshopid: bodyshopId, phone_number } + }, + (data) => { + if (!data?.phone_number_consent?.[0]) { + return { + phone_number_consent: [ + { + __typename: "phone_number_consent", + id: null, + bodyshopid: bodyshopId, + phone_number, + consent_status, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + consent_updated_at: new Date().toISOString(), + history: [] + } + ] + }; + } + return { + phone_number_consent: [ + { + ...data.phone_number_consent[0], + consent_status, + consent_updated_at: new Date().toISOString() + } + ] + }; + } + ); + } catch (error) { + console.error("Error updating consent cache:", error); + } + }; + + socket.on("consent-changed", handleConsentChanged); + + return () => { + socket.off("consent-changed", handleConsentChanged); unregisterMessagingHandlers({ socket }); - } - }; + }; + } }, [bodyshop, socket, t, client]); if (!bodyshop || !bodyshop.messagingservicesid) return <>; diff --git a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx index 16d4c0bf1..ede2a2570 100644 --- a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx +++ b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx @@ -10,6 +10,10 @@ import PhoneFormatter from "../../utils/PhoneFormatter"; import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component"; import _ from "lodash"; import "./chat-conversation-list.styles.scss"; +import { useQuery } from "@apollo/client"; +import { GET_PHONE_NUMBER_CONSENTS } from "../../graphql/consent.queries.js"; +import { phone } from "phone"; +import { useTranslation } from "react-i18next"; const mapStateToProps = createStructuredSelector({ selectedConversation: selectSelectedConversation @@ -20,25 +24,45 @@ const mapDispatchToProps = (dispatch) => ({ }); function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) { - // That comma is there for a reason, do not remove it + const { t } = useTranslation(); const [, forceUpdate] = useState(false); - // Re-render every minute + // Normalize phone numbers and fetch consent statuses + 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: !conversationList.length || !conversationList[0]?.bodyshopid, + fetchPolicy: "cache-and-network" + }); + + // Create a map of phone number to consent status + const consentMap = React.useMemo(() => { + const map = new Map(); + consentData?.phone_number_consent?.forEach((consent) => { + map.set(consent.phone_number, consent.consent_status); + }); + return map; + }, [consentData]); + useEffect(() => { const interval = setInterval(() => { - forceUpdate((prev) => !prev); // Toggle state to trigger re-render - }, 60000); // 1 minute in milliseconds - - return () => clearInterval(interval); // Cleanup on unmount + forceUpdate((prev) => !prev); + }, 60000); + return () => clearInterval(interval); }, []); - // Memoize the sorted conversation list const sortedConversationList = React.useMemo(() => { return _.orderBy(conversationList, ["updated_at"], ["desc"]); }, [conversationList]); - const renderConversation = (index) => { + const renderConversation = (index, t) => { const item = sortedConversationList[index]; + const normalizedPhone = phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""); + const isConsented = consentMap.get(normalizedPhone) ?? false; + const cardContentRight = {item.updated_at}; const cardContentLeft = item.job_conversations.length > 0 @@ -60,7 +84,12 @@ function ChatConversationListComponent({ conversationList, selectedConversation, ); - const cardExtra = ; + const cardExtra = ( + <> + + {!isConsented && {t("messaging.labels.no_consent")}} + + ); const getCardStyle = () => item.id === selectedConversation @@ -73,7 +102,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation, onClick={() => setSelectedConversation(item.id)} className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`} > - +
{cardContentLeft}
{cardContentRight}
@@ -85,7 +114,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
renderConversation(index)} + itemContent={(index) => renderConversation(index, t)} style={{ height: "100%", width: "100%" }} />
diff --git a/client/src/components/chat-conversation-list/chat-conversation-list.styles.scss b/client/src/components/chat-conversation-list/chat-conversation-list.styles.scss index e6169777c..86cf06152 100644 --- a/client/src/components/chat-conversation-list/chat-conversation-list.styles.scss +++ b/client/src/components/chat-conversation-list/chat-conversation-list.styles.scss @@ -24,7 +24,7 @@ /* Add spacing and better alignment for items */ .chat-list-item { - padding: 0.5rem 0; /* Add spacing between list items */ + padding: 0.2rem 0; /* Add spacing between list items */ .ant-card { border-radius: 8px; /* Slight rounding for card edges */ diff --git a/client/src/components/chat-media-selector/chat-media-selector.component.jsx b/client/src/components/chat-media-selector/chat-media-selector.component.jsx index b7e7d64a3..162789fb6 100644 --- a/client/src/components/chat-media-selector/chat-media-selector.component.jsx +++ b/client/src/components/chat-media-selector/chat-media-selector.component.jsx @@ -37,7 +37,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c fetchPolicy: "network-only", nextFetchPolicy: "network-only", 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 @@ -67,14 +67,14 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c <> {!bodyshop.uselocalmediaserver && ( )} {bodyshop.uselocalmediaserver && open && ( )} @@ -89,7 +89,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c {bodyshop.uselocalmediaserver && open && ( )} diff --git a/client/src/components/chat-send-message/chat-send-message.component.jsx b/client/src/components/chat-send-message/chat-send-message.component.jsx index 824d5e591..29e5bb8c6 100644 --- a/client/src/components/chat-send-message/chat-send-message.component.jsx +++ b/client/src/components/chat-send-message/chat-send-message.component.jsx @@ -10,6 +10,10 @@ import { selectIsSending, selectMessage } from "../../redux/messaging/messaging. import { selectBodyshop } from "../../redux/user/user.selectors"; import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component"; import ChatPresetsComponent from "../chat-presets/chat-presets.component"; +import { useQuery } from "@apollo/client"; +import { GET_PHONE_NUMBER_CONSENT } from "../../graphql/consent.queries"; +import AlertComponent from "../alert/alert.component"; +import { phone } from "phone"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -25,16 +29,23 @@ const mapDispatchToProps = (dispatch) => ({ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) { const inputArea = useRef(null); const [selectedMedia, setSelectedMedia] = useState([]); + const { t } = useTranslation(); + + const normalizedPhone = phone(conversation.phone_num, "CA").phoneNumber.replace(/^\+1/, ""); + const { data: consentData } = useQuery(GET_PHONE_NUMBER_CONSENT, { + variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone }, + fetchPolicy: "cache-and-network" + }); + const isConsented = consentData?.phone_number_consent?.[0]?.consent_status ?? false; useEffect(() => { inputArea.current.focus(); }, [isSending, setMessage]); - const { t } = useTranslation(); - const handleEnter = () => { const selectedImages = selectedMedia.filter((i) => i.isSelected); if ((message === "" || !message) && selectedImages.length === 0) return; + if (!isConsented) return; logImEXEvent("messaging_send_message"); if (selectedImages.length < 11) { @@ -44,7 +55,8 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi messagingServiceSid: bodyshop.messagingservicesid, conversationid: conversation.id, selectedMedia: selectedImages, - imexshopid: bodyshop.imexshopid + imexshopid: bodyshop.imexshopid, + bodyshopid: bodyshop.id }; sendMessage(newMessage); setSelectedMedia( @@ -57,6 +69,9 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi return (
+ {!isConsented && ( + + )} setMessage(e.target.value)} onPressEnter={(event) => { event.preventDefault(); - if (!!!event.shiftKey) handleEnter(); + if (!event.shiftKey && isConsented) handleEnter(); }} /> ({}); + +function PhoneNumberConsentList({ bodyshop }) { + const { t } = useTranslation(); + const [search, setSearch] = useState(""); + const { loading, data } = useQuery(GET_PHONE_NUMBER_CONSENTS, { + variables: { bodyshopid: bodyshop.id, search }, + fetchPolicy: "network-only" + }); + const [setConsent] = useMutation(SET_PHONE_NUMBER_CONSENT); + const [bulkSetConsent] = useMutation(BULK_SET_PHONE_NUMBER_CONSENT); + + const columns = [ + { + title: t("consent.phone_number"), + dataIndex: "phone_number", + render: (text) => {text} + }, + { + title: t("consent.status"), + dataIndex: "consent_status", + render: (status, record) => ( + + + setConsent({ + variables: { + bodyshopid: bodyshop.id, + phone_number: record.phone_number, + consent_status: checked, + reason: "Manual override in app", + changed_by: "user" // Replace with actual user email from context + }, + optimisticResponse: { + insert_phone_number_consent_one: { + __typename: "phone_number_consent", + id: record.id, + bodyshopid: bodyshop.id, + phone_number: record.phone_number, + consent_status: checked, + created_at: record.created_at, + updated_at: new Date().toISOString(), + consent_updated_at: new Date().toISOString() + } + } + }) + } + /> + + ) + }, + { + title: t("consent.updated_at"), + dataIndex: "consent_updated_at", + render: (text) => {text} + } + ]; + + 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 objects = lines + .filter((line) => line.trim()) + .map((line) => { + const [phone_number, consent_status] = line.split(","); + return { + bodyshopid: bodyshop.id, + phone_number: phone(phone_number, "CA").phoneNumber.replace(/^\+1/, ""), + consent_status: consent_status.trim().toLowerCase() === "true" + }; + }); + + try { + await bulkSetConsent({ + variables: { objects }, + context: { headers: { "x-reason": "System update via bulk upload", "x-changed-by": "system" } } + }); + } catch (error) { + console.error("Bulk upload failed:", error); + } + }; + reader.readAsText(file); + return false; + }; + + return ( +
+ setSearch(value)} + style={{ marginBottom: 16 }} + /> + + + + + + ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(PhoneNumberConsentList); diff --git a/client/src/components/shop-info/shop-info.consent.component.jsx b/client/src/components/shop-info/shop-info.consent.component.jsx new file mode 100644 index 000000000..74975e02d --- /dev/null +++ b/client/src/components/shop-info/shop-info.consent.component.jsx @@ -0,0 +1,51 @@ +import { useMutation } from "@apollo/client"; +import { Switch, 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 { UPDATE_BODYSHOP_ENFORCE_CONSENT } from "../../graphql/bodyshop.queries"; +import PhoneNumberConsentList from "../phone-number-consent/phone-number-consent.component"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop +}); + +const mapDispatchToProps = () => ({}); + +function ShopInfoConsentComponent({ bodyshop }) { + const { t } = useTranslation(); + + const [updateEnforceConsent] = useMutation(UPDATE_BODYSHOP_ENFORCE_CONSENT); + + console.dir(bodyshop); + + const enforceConsent = bodyshop?.enforce_sms_consent ?? false; + + return ( +
+ {t("settings.title")} +
+ {t("settings.enforce_sms_consent")} + + updateEnforceConsent({ + variables: { id: bodyshop.id, enforce_sms_consent: checked }, + optimisticResponse: { + update_bodyshops_by_pk: { + __typename: "bodyshops", + id: bodyshop.id, + enforce_sms_consent: checked + } + } + }) + } + /> +
+ +
+ ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoConsentComponent); diff --git a/client/src/graphql/bodyshop.queries.js b/client/src/graphql/bodyshop.queries.js index 7faff13a2..af16899cf 100644 --- a/client/src/graphql/bodyshop.queries.js +++ b/client/src/graphql/bodyshop.queries.js @@ -142,6 +142,7 @@ export const QUERY_BODYSHOP = gql` intellipay_config md_ro_guard notification_followers + enforce_sms_consent employee_teams(order_by: { name: asc }, where: { active: { _eq: true } }) { id name @@ -363,3 +364,12 @@ 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 + } + } +`; diff --git a/client/src/graphql/consent.queries.js b/client/src/graphql/consent.queries.js new file mode 100644 index 000000000..8a3f78c8f --- /dev/null +++ b/client/src/graphql/consent.queries.js @@ -0,0 +1,90 @@ +import { gql } from "@apollo/client"; + +export const GET_PHONE_NUMBER_CONSENT = gql` + 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 + history(order_by: { changed_at: desc }, limit: 1) { + reason + } + } + } +`; + +export const GET_PHONE_NUMBER_CONSENTS = gql` + query GET_PHONE_NUMBER_CONSENTS($bodyshopid: uuid!, $phone_numbers: [String!]) { + phone_number_consent( + where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _in: $phone_numbers } } + order_by: { consent_updated_at: desc } + ) { + id + bodyshopid + phone_number + consent_status + created_at + updated_at + consent_updated_at + history(order_by: { changed_at: desc }, limit: 1) { + reason + } + } + } +`; + +export const SET_PHONE_NUMBER_CONSENT = gql` + mutation SET_PHONE_NUMBER_CONSENT( + $bodyshopid: uuid! + $phone_number: String! + $consent_status: Boolean! + $reason: String! + $changed_by: String! + ) { + 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 + } + } +`; + +export const BULK_SET_PHONE_NUMBER_CONSENT = gql` + 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 + consent_updated_at + } + } + } +`; diff --git a/client/src/pages/shop/shop.page.component.jsx b/client/src/pages/shop/shop.page.component.jsx index b4b354b1d..b6ded16f6 100644 --- a/client/src/pages/shop/shop.page.component.jsx +++ b/client/src/pages/shop/shop.page.component.jsx @@ -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 ShopInfoContainer from "../../components/shop-info/shop-info.container"; 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 { selectBodyshop } from "../../redux/user/user.selectors"; import InstanceRenderManager from "../../utils/instanceRenderMgr"; - import { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component"; import ShopTeamsContainer from "../../components/shop-teams/shop-teams.container"; @@ -91,6 +91,14 @@ export function ShopPage({ bodyshop, setSelectedHeader, setBreadcrumbs }) { children: }); } + + // Add Consent Settings tab + items.push({ + key: "consent", + label: t("bodyshop.labels.consent_settings"), + children: + }); + return ( history({ search: `?tab=${key}` })} items={items} /> diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index d61d814b9..4ed9e5905 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2805,6 +2805,7 @@ exports.GET_BODYSHOP_BY_ID = ` intellipay_config state notification_followers + enforce_sms_consent } } `; @@ -2968,3 +2969,103 @@ exports.GET_JOB_WATCHERS_MINIMAL = ` } } `; + +// Query to get consent status for a 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 + history(order_by: { changed_at: desc }) { + id + old_value + new_value + reason + changed_at + changed_by + } + } + } +`; + +// Query to get consent history +exports.GET_PHONE_NUMBER_CONSENT_HISTORY = ` + query GET_PHONE_NUMBER_CONSENT_HISTORY($phone_number_consent_id: uuid!) { + phone_number_consent_history(where: { phone_number_consent_id: { _eq: $phone_number_consent_id } }, order_by: { changed_at: desc }) { + id + phone_number_consent_id + old_value + new_value + reason + changed_at + changed_by + } + } +`; + +// Mutation to set consent status +exports.SET_PHONE_NUMBER_CONSENT = ` + mutation SET_PHONE_NUMBER_CONSENT($bodyshopid: uuid!, $phone_number: String!, $consent_status: Boolean!, $reason: String!) { + 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 + } + insert_phone_number_consent_history_one( + object: { + phone_number_consent_id: $id + old_value: $old_value + new_value: $consent_status + reason: $reason + changed_by: $changed_by + } + ) { + id + reason + changed_at + changed_by + } + } +`; + +// Mutation for bulk consent updates +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 + consent_updated_at + } + } + } +`; diff --git a/server/sms/receive.js b/server/sms/receive.js index f880105e9..128fda2ff 100644 --- a/server/sms/receive.js +++ b/server/sms/receive.js @@ -3,7 +3,8 @@ const { FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, UNARCHIVE_CONVERSATION, CREATE_CONVERSATION, - INSERT_MESSAGE + INSERT_MESSAGE, + SET_PHONE_NUMBER_CONSENT } = require("../graphql-client/queries"); const { phone } = require("phone"); const { admin } = require("../firebase/firebase-handler"); @@ -91,7 +92,30 @@ const receive = async (req, res) => { const bodyshop = response.bodyshops[0]; - // Sort conversations by `updated_at` (or `created_at`) and pick the last one + // Step 2: Handle consent + 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 3: Process conversation const sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const existingConversation = sortedConversations.length ? sortedConversations[sortedConversations.length - 1] @@ -104,14 +128,11 @@ const receive = async (req, res) => { image: !!req.body.MediaUrl0, image_path: generateMediaArray(req.body), isoutbound: false, - userid: null // Add additional fields as necessary + userid: null }; if (existingConversation) { - // Use the existing conversation conversationid = existingConversation.id; - - // Unarchive the conversation if necessary if (existingConversation.archived) { await client.request(UNARCHIVE_CONVERSATION, { id: conversationid, @@ -119,11 +140,10 @@ const receive = async (req, res) => { }); } } else { - // Create a new conversation const newConversationResponse = await client.request(CREATE_CONVERSATION, { conversation: { bodyshopid: bodyshop.id, - phone_num: phone(req.body.From).phoneNumber, + phone_num: normalizedPhone, archived: false } }); @@ -131,13 +151,12 @@ const receive = async (req, res) => { conversationid = createdConversation.id; } - // Ensure `conversationid` is added to the message newMessage.conversationid = conversationid; - // Step 3: Insert the message into the conversation + // Step 4: Insert the message const insertresp = await client.request(INSERT_MESSAGE, { msg: newMessage, - conversationid: conversationid + conversationid }); const message = insertresp?.insert_messages?.returning?.[0]; @@ -147,8 +166,7 @@ const receive = async (req, res) => { throw new Error("Conversation data is missing from the response."); } - // Step 4: Notify clients through Redis - const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id); + // Step 5: Notify clients const conversationRoom = getBodyshopConversationRoom({ bodyshopId: conversation.bodyshop.id, conversationId: conversation.id @@ -176,7 +194,7 @@ const receive = async (req, res) => { summary: false }); - // Step 5: Send FCM notification + // Step 6: Send FCM notification const fcmresp = await admin.messaging().send({ topic: `${message.conversation.bodyshop.imexshopid}-messaging`, notification: { From 7bd5190bf2f813bf5477b45297ee3b5108ba550f Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 20 May 2025 18:19:39 -0400 Subject: [PATCH 2/5] feature/IO-3182-Phone-Number-Consent - Checkpoint --- .../chat-affix/chat-affix.container.jsx | 75 +++-- .../chat-conversation-list.component.jsx | 44 ++- .../chat-send-message.component.jsx | 13 +- .../phone-number-consent.component.jsx | 300 +++++++++++++----- .../shop-info/shop-info.consent.component.jsx | 58 ++-- client/src/graphql/consent.queries.js | 72 +---- client/src/redux/user/user.actions.js | 5 + client/src/redux/user/user.reducer.js | 9 +- client/src/redux/user/user.types.js | 3 +- hasura/metadata/tables.yaml | 2 +- .../down.sql | 1 + .../up.sql | 1 + server/graphql-client/queries.js | 87 +++-- server/routes/smsRoutes.js | 4 +- server/sms/consent.js | 215 +++++++++++++ server/sms/receive.js | 150 +++++---- server/sms/send.js | 53 +++- 17 files changed, 772 insertions(+), 320 deletions(-) create mode 100644 hasura/migrations/1747775597734_alter_table_public_phone_number_consent_history_alter_column_old_value/down.sql create mode 100644 hasura/migrations/1747775597734_alter_table_public_phone_number_consent_history_alter_column_old_value/up.sql create mode 100644 server/sms/consent.js diff --git a/client/src/components/chat-affix/chat-affix.container.jsx b/client/src/components/chat-affix/chat-affix.container.jsx index cf8afce3b..85b06f2e8 100644 --- a/client/src/components/chat-affix/chat-affix.container.jsx +++ b/client/src/components/chat-affix/chat-affix.container.jsx @@ -8,13 +8,15 @@ import ChatPopupComponent from "../chat-popup/chat-popup.component"; import "./chat-affix.styles.scss"; import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers"; import { useSocket } from "../../contexts/SocketIO/useSocket.js"; -import { GET_PHONE_NUMBER_CONSENT } from "../../graphql/consent.queries.js"; +import { GET_PHONE_NUMBER_CONSENT } from "../../graphql/consent.queries"; export function ChatAffixContainer({ bodyshop, chatVisible }) { const { t } = useTranslation(); const client = useApolloClient(); const { socket } = useSocket(); + const enforceConsent = bodyshop?.enforce_sms_consent ?? false; + useEffect(() => { if (!bodyshop || !bodyshop.messagingservicesid) return; @@ -39,45 +41,52 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) { if (socket && socket.connected) { registerMessagingHandlers({ socket, client }); - // Handle consent-changed events - const handleConsentChanged = ({ bodyshopId, phone_number, consent_status }) => { + // 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 { - client.cache.writeQuery( + 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 } }, - (data) => { - if (!data?.phone_number_consent?.[0]) { - return { - phone_number_consent: [ - { - __typename: "phone_number_consent", - id: null, - bodyshopid: bodyshopId, - phone_number, - consent_status, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - consent_updated_at: new Date().toISOString(), - history: [] - } - ] - }; - } - return { - phone_number_consent: [ - { - ...data.phone_number_consent[0], - consent_status, - consent_updated_at: new Date().toISOString() - } - ] - }; + { + phone_number_consent: [updatedConsent] } ); + + console.log("Cache update in handleConsentChanged:", { phone_number, consent_status, updatedConsent }); } catch (error) { - console.error("Error updating consent cache:", error); + console.error("Error updating consent cache in handleConsentChanged:", error.message, error.stack); } }; @@ -88,7 +97,7 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) { unregisterMessagingHandlers({ socket }); }; } - }, [bodyshop, socket, t, client]); + }, [bodyshop, socket, t, client, enforceConsent]); if (!bodyshop || !bodyshop.messagingservicesid) return <>; diff --git a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx index ede2a2570..3bdc9cd28 100644 --- a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx +++ b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx @@ -1,5 +1,5 @@ import { Badge, Card, List, Space, Tag } from "antd"; -import React, { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; import { connect } from "react-redux"; import { Virtuoso } from "react-virtuoso"; import { createStructuredSelector } from "reselect"; @@ -11,35 +11,37 @@ import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-displ import _ from "lodash"; import "./chat-conversation-list.styles.scss"; import { useQuery } from "@apollo/client"; -import { GET_PHONE_NUMBER_CONSENTS } from "../../graphql/consent.queries.js"; +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({ - selectedConversation: selectSelectedConversation + selectedConversation: selectSelectedConversation, + bodyshop: selectBodyshop }); const mapDispatchToProps = (dispatch) => ({ setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId)) }); -function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) { +function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) { const { t } = useTranslation(); const [, forceUpdate] = useState(false); - // Normalize phone numbers and fetch consent statuses + const enforceConsent = bodyshop?.enforce_sms_consent ?? false; + 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: !conversationList.length || !conversationList[0]?.bodyshopid, + skip: !enforceConsent || !conversationList.length || !conversationList[0]?.bodyshopid, fetchPolicy: "cache-and-network" }); - // Create a map of phone number to consent status - const consentMap = React.useMemo(() => { + const consentMap = useMemo(() => { const map = new Map(); consentData?.phone_number_consent?.forEach((consent) => { map.set(consent.phone_number, consent.consent_status); @@ -54,14 +56,14 @@ function ChatConversationListComponent({ conversationList, selectedConversation, return () => clearInterval(interval); }, []); - const sortedConversationList = React.useMemo(() => { + const sortedConversationList = useMemo(() => { return _.orderBy(conversationList, ["updated_at"], ["desc"]); }, [conversationList]); const renderConversation = (index, t) => { const item = sortedConversationList[index]; const normalizedPhone = phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""); - const isConsented = consentMap.get(normalizedPhone) ?? false; + const isConsented = enforceConsent ? (consentMap.get(normalizedPhone) ?? false) : true; const cardContentRight = {item.updated_at}; const cardContentLeft = @@ -87,7 +89,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation, const cardExtra = ( <> - {!isConsented && {t("messaging.labels.no_consent")}} + {enforceConsent && !isConsented && {t("messaging.labels.no_consent")}} ); @@ -103,8 +105,24 @@ function ChatConversationListComponent({ conversationList, selectedConversation, className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`} > -
{cardContentLeft}
-
{cardContentRight}
+
+ {cardContentLeft} +
+
+ {cardContentRight} +
); diff --git a/client/src/components/chat-send-message/chat-send-message.component.jsx b/client/src/components/chat-send-message/chat-send-message.component.jsx index 29e5bb8c6..a42946113 100644 --- a/client/src/components/chat-send-message/chat-send-message.component.jsx +++ b/client/src/components/chat-send-message/chat-send-message.component.jsx @@ -1,5 +1,5 @@ import { LoadingOutlined, SendOutlined } from "@ant-design/icons"; -import { Input, Spin } from "antd"; +import { Input, Spin, Alert } from "antd"; import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; @@ -31,12 +31,15 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi const [selectedMedia, setSelectedMedia] = useState([]); const { t } = useTranslation(); + const enforceConsent = bodyshop?.enforce_sms_consent ?? false; + const normalizedPhone = phone(conversation.phone_num, "CA").phoneNumber.replace(/^\+1/, ""); const { data: consentData } = useQuery(GET_PHONE_NUMBER_CONSENT, { variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone }, - fetchPolicy: "cache-and-network" + fetchPolicy: "cache-and-network", + skip: !enforceConsent }); - const isConsented = consentData?.phone_number_consent?.[0]?.consent_status ?? false; + const isConsented = enforceConsent ? (consentData?.phone_number_consent?.[0]?.consent_status ?? false) : true; useEffect(() => { inputArea.current.focus(); @@ -69,8 +72,8 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi return (
- {!isConsented && ( - + {enforceConsent && !isConsented && ( + )} ({}); -function PhoneNumberConsentList({ bodyshop }) { +function PhoneNumberConsentList({ bodyshop, currentUser }) { const { t } = useTranslation(); const [search, setSearch] = useState(""); - const { loading, data } = useQuery(GET_PHONE_NUMBER_CONSENTS, { - variables: { bodyshopid: bodyshop.id, search }, + const notification = useNotification(); + const { loading, data, refetch } = useQuery(GET_PHONE_NUMBER_CONSENTS, { + variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined }, fetchPolicy: "network-only" }); - const [setConsent] = useMutation(SET_PHONE_NUMBER_CONSENT); - const [bulkSetConsent] = useMutation(BULK_SET_PHONE_NUMBER_CONSENT); + const client = useApolloClient(); + 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 = [ { title: t("consent.phone_number"), dataIndex: "phone_number", - render: (text) => {text} + render: (text) => {text}, + sorter: (a, b) => a.phone_number.localeCompare(b.phone_number) }, { title: t("consent.status"), dataIndex: "consent_status", render: (status, record) => ( - - - setConsent({ - variables: { - bodyshopid: bodyshop.id, - phone_number: record.phone_number, - consent_status: checked, - reason: "Manual override in app", - changed_by: "user" // Replace with actual user email from context - }, - optimisticResponse: { - insert_phone_number_consent_one: { - __typename: "phone_number_consent", - id: record.id, - bodyshopid: bodyshop.id, - phone_number: record.phone_number, - consent_status: checked, - created_at: record.created_at, - updated_at: new Date().toISOString(), - consent_updated_at: new Date().toISOString() - } - } - }) - } - /> + + handleSetConsent(record.phone_number, checked)} /> ) }, @@ -78,35 +265,6 @@ function PhoneNumberConsentList({ bodyshop }) { } ]; - 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 objects = lines - .filter((line) => line.trim()) - .map((line) => { - const [phone_number, consent_status] = line.split(","); - return { - bodyshopid: bodyshop.id, - phone_number: phone(phone_number, "CA").phoneNumber.replace(/^\+1/, ""), - consent_status: consent_status.trim().toLowerCase() === "true" - }; - }); - - try { - await bulkSetConsent({ - variables: { objects }, - context: { headers: { "x-reason": "System update via bulk upload", "x-changed-by": "system" } } - }); - } catch (error) { - console.error("Bulk upload failed:", error); - } - }; - reader.readAsText(file); - return false; - }; - return (
({}); +const mapDispatchToProps = (dispatch) => ({ + updateBodyshopEnforceConsent: (enforce_sms_consent) => dispatch(updateBodyshopEnforceConsent(enforce_sms_consent)) +}); -function ShopInfoConsentComponent({ bodyshop }) { +function ShopInfoConsentComponent({ bodyshop, updateBodyshopEnforceConsent }) { const { t } = useTranslation(); - const [updateEnforceConsent] = useMutation(UPDATE_BODYSHOP_ENFORCE_CONSENT); - - console.dir(bodyshop); + 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; @@ -27,23 +37,29 @@ function ShopInfoConsentComponent({ bodyshop }) { {t("settings.title")}
{t("settings.enforce_sms_consent")} - - updateEnforceConsent({ - variables: { id: bodyshop.id, enforce_sms_consent: checked }, - optimisticResponse: { - update_bodyshops_by_pk: { - __typename: "bodyshops", - id: bodyshop.id, - enforce_sms_consent: 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} + /> +
- + {enforceConsent && }
); } diff --git a/client/src/graphql/consent.queries.js b/client/src/graphql/consent.queries.js index 8a3f78c8f..66b15f454 100644 --- a/client/src/graphql/consent.queries.js +++ b/client/src/graphql/consent.queries.js @@ -10,18 +10,23 @@ export const GET_PHONE_NUMBER_CONSENT = gql` created_at updated_at consent_updated_at - history(order_by: { changed_at: desc }, limit: 1) { + phone_number_consent_history(order_by: { changed_at: desc }, limit: 1) { + id reason + changed_at + old_value + new_value + changed_by } } } `; export const GET_PHONE_NUMBER_CONSENTS = gql` - query GET_PHONE_NUMBER_CONSENTS($bodyshopid: uuid!, $phone_numbers: [String!]) { + query GET_PHONE_NUMBER_CONSENTS($bodyshopid: uuid!, $search: String) { phone_number_consent( - where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _in: $phone_numbers } } - order_by: { consent_updated_at: desc } + where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _ilike: $search } } + order_by: [{ phone_number: asc }, { consent_updated_at: desc }] ) { id bodyshopid @@ -30,60 +35,13 @@ export const GET_PHONE_NUMBER_CONSENTS = gql` created_at updated_at consent_updated_at - history(order_by: { changed_at: desc }, limit: 1) { - reason - } - } - } -`; - -export const SET_PHONE_NUMBER_CONSENT = gql` - mutation SET_PHONE_NUMBER_CONSENT( - $bodyshopid: uuid! - $phone_number: String! - $consent_status: Boolean! - $reason: String! - $changed_by: String! - ) { - 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 - } - } -`; - -export const BULK_SET_PHONE_NUMBER_CONSENT = gql` - 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 { + phone_number_consent_history(order_by: { changed_at: desc }, limit: 1) { id - bodyshopid - phone_number - consent_status - consent_updated_at + reason + changed_at + old_value + new_value + changed_by } } } diff --git a/client/src/redux/user/user.actions.js b/client/src/redux/user/user.actions.js index 01ba22534..125aab415 100644 --- a/client/src/redux/user/user.actions.js +++ b/client/src/redux/user/user.actions.js @@ -123,3 +123,8 @@ export const setImexShopId = (imexshopid) => ({ type: UserActionTypes.SET_IMEX_SHOP_ID, payload: imexshopid }); + +export const updateBodyshopEnforceConsent = (enforce_sms_consent) => ({ + type: UserActionTypes.UPDATE_BODYSHOP_ENFORCE_CONSENT, + payload: enforce_sms_consent +}); diff --git a/client/src/redux/user/user.reducer.js b/client/src/redux/user/user.reducer.js index 0042115ff..a72d4f068 100644 --- a/client/src/redux/user/user.reducer.js +++ b/client/src/redux/user/user.reducer.js @@ -105,7 +105,6 @@ const userReducer = (state = INITIAL_STATE, action) => { ...action.payload //Spread current user details in. } }; - case UserActionTypes.SET_SHOP_DETAILS: return { ...state, @@ -126,6 +125,14 @@ const userReducer = (state = INITIAL_STATE, action) => { ...state, imexshopid: action.payload }; + case UserActionTypes.UPDATE_BODYSHOP_ENFORCE_CONSENT: + return { + ...state, + bodyshop: { + ...state.bodyshop, + enforce_sms_consent: action.payload + } + }; default: return state; } diff --git a/client/src/redux/user/user.types.js b/client/src/redux/user/user.types.js index d9cd6fe62..ff21dbb5a 100644 --- a/client/src/redux/user/user.types.js +++ b/client/src/redux/user/user.types.js @@ -33,6 +33,7 @@ const UserActionTypes = { CHECK_ACTION_CODE_FAILURE: "CHECK_ACTION_CODE_FAILURE", SET_CURRENT_EULA: "SET_CURRENT_EULA", 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; diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 17c162e07..fd83ecbc4 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -5871,7 +5871,7 @@ using: foreign_key_constraint_on: bodyshopid array_relationships: - - name: phone_number_consent_histories + - name: phone_number_consent_history using: foreign_key_constraint_on: column: phone_number_consent_id diff --git a/hasura/migrations/1747775597734_alter_table_public_phone_number_consent_history_alter_column_old_value/down.sql b/hasura/migrations/1747775597734_alter_table_public_phone_number_consent_history_alter_column_old_value/down.sql new file mode 100644 index 000000000..fb33566b8 --- /dev/null +++ b/hasura/migrations/1747775597734_alter_table_public_phone_number_consent_history_alter_column_old_value/down.sql @@ -0,0 +1 @@ +alter table "public"."phone_number_consent_history" alter column "old_value" set not null; diff --git a/hasura/migrations/1747775597734_alter_table_public_phone_number_consent_history_alter_column_old_value/up.sql b/hasura/migrations/1747775597734_alter_table_public_phone_number_consent_history_alter_column_old_value/up.sql new file mode 100644 index 000000000..406cf8efc --- /dev/null +++ b/hasura/migrations/1747775597734_alter_table_public_phone_number_consent_history_alter_column_old_value/up.sql @@ -0,0 +1 @@ +alter table "public"."phone_number_consent_history" alter column "old_value" drop not null; diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 4ed9e5905..84e20db6f 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2970,7 +2970,7 @@ exports.GET_JOB_WATCHERS_MINIMAL = ` } `; -// Query to get consent status for a phone number +// 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 } }) { @@ -2981,7 +2981,7 @@ exports.GET_PHONE_NUMBER_CONSENT = ` created_at updated_at consent_updated_at - history(order_by: { changed_at: desc }) { + phone_number_consent_history(order_by: { changed_at: desc }) { id old_value new_value @@ -2993,24 +2993,45 @@ exports.GET_PHONE_NUMBER_CONSENT = ` } `; -// Query to get consent history -exports.GET_PHONE_NUMBER_CONSENT_HISTORY = ` - query GET_PHONE_NUMBER_CONSENT_HISTORY($phone_number_consent_id: uuid!) { - phone_number_consent_history(where: { phone_number_consent_id: { _eq: $phone_number_consent_id } }, order_by: { changed_at: desc }) { +// 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 - phone_number_consent_id - old_value - new_value - reason - changed_at - changed_by + 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 set consent status +// 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!, $reason: String!) { + mutation SET_PHONE_NUMBER_CONSENT($bodyshopid: uuid!, $phone_number: String!, $consent_status: Boolean!) { insert_phone_number_consent_one( object: { bodyshopid: $bodyshopid @@ -3031,24 +3052,10 @@ exports.SET_PHONE_NUMBER_CONSENT = ` updated_at consent_updated_at } - insert_phone_number_consent_history_one( - object: { - phone_number_consent_id: $id - old_value: $old_value - new_value: $consent_status - reason: $reason - changed_by: $changed_by - } - ) { - id - reason - changed_at - changed_by - } } `; -// Mutation for bulk consent updates +// 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( @@ -3064,8 +3071,30 @@ exports.BULK_SET_PHONE_NUMBER_CONSENT = ` 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 + } + } + } +`; diff --git a/server/routes/smsRoutes.js b/server/routes/smsRoutes.js index 1b169747d..917699aaf 100644 --- a/server/routes/smsRoutes.js +++ b/server/routes/smsRoutes.js @@ -5,14 +5,16 @@ const { receive } = require("../sms/receive"); const { send } = require("../sms/send"); const { status, markConversationRead } = require("../sms/status"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); +const { setConsent, bulkSetConsent } = require("../sms/consent"); // Twilio Webhook Middleware for production -// TODO: Look into this because it technically is never validating anything const twilioWebhookMiddleware = twilio.webhook({ validate: process.env.NODE_ENV === "PRODUCTION" }); router.post("/receive", twilioWebhookMiddleware, receive); router.post("/send", validateFirebaseIdTokenMiddleware, send); router.post("/status", twilioWebhookMiddleware, status); router.post("/markConversationRead", validateFirebaseIdTokenMiddleware, markConversationRead); +router.post("/setConsent", validateFirebaseIdTokenMiddleware, setConsent); +router.post("/bulkSetConsent", validateFirebaseIdTokenMiddleware, bulkSetConsent); module.exports = router; diff --git a/server/sms/consent.js b/server/sms/consent.js new file mode 100644 index 000000000..d7a997423 --- /dev/null +++ b/server/sms/consent.js @@ -0,0 +1,215 @@ +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 +}; diff --git a/server/sms/receive.js b/server/sms/receive.js index 128fda2ff..59c4b1b32 100644 --- a/server/sms/receive.js +++ b/server/sms/receive.js @@ -8,65 +8,27 @@ const { } = require("../graphql-client/queries"); const { phone } = require("phone"); const { admin } = require("../firebase/firebase-handler"); -const logger = require("../utils/logger"); const InstanceManager = require("../utils/instanceMgr").default; /** - * Generate an array of media URLs from the request body - * @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 + * Receive SMS messages from Twilio and process them * @param req * @param res * @returns {Promise<*>} */ const receive = async (req, res) => { const { + logger, ioRedis, - ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } + ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }, + sessionUtils: { getBodyshopFromRedis } } = req; const loggerData = { msid: req.body.SmsMessageSid, text: req.body.Body, image: !!req.body.MediaUrl0, - image_path: generateMediaArray(req.body) + image_path: generateMediaArray(req.body, logger) }; logger.log("sms-inbound", "DEBUG", "api", null, loggerData); @@ -92,30 +54,36 @@ const receive = async (req, res) => { const bodyshop = response.bodyshops[0]; - // Step 2: Handle consent - 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"; + // Step 2: Check enforce_sms_consent + const bodyShopData = await getBodyshopFromRedis(bodyshopid); + const enforceConsent = bodyShopData?.enforce_sms_consent ?? false; - const consentResponse = await client.request(SET_PHONE_NUMBER_CONSENT, { - bodyshopid: bodyshop.id, - phone_number: normalizedPhone, - consent_status: consentStatus, - reason, - changed_by: "system" - }); + // 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"; - // 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 - }); + const consentResponse = await client.request(SET_PHONE_NUMBER_CONSENT, { + bodyshopid: bodyshop.id, + phone_number: normalizedPhone, + consent_status: consentStatus, + reason, + changed_by: "system" + }); - // Step 3: Process conversation + // 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 const sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const existingConversation = sortedConversations.length ? sortedConversations[sortedConversations.length - 1] @@ -126,7 +94,7 @@ const receive = async (req, res) => { msid: req.body.SmsMessageSid, text: req.body.Body, image: !!req.body.MediaUrl0, - image_path: generateMediaArray(req.body), + image_path: generateMediaArray(req.body, logger), isoutbound: false, userid: null }; @@ -143,7 +111,7 @@ const receive = async (req, res) => { const newConversationResponse = await client.request(CREATE_CONVERSATION, { conversation: { bodyshopid: bodyshop.id, - phone_num: normalizedPhone, + phone_num: phone(req.body.From).phoneNumber, archived: false } }); @@ -153,7 +121,7 @@ const receive = async (req, res) => { newMessage.conversationid = conversationid; - // Step 4: Insert the message + // Step 5: Insert the message const insertresp = await client.request(INSERT_MESSAGE, { msg: newMessage, conversationid @@ -166,7 +134,7 @@ const receive = async (req, res) => { throw new Error("Conversation data is missing from the response."); } - // Step 5: Notify clients + // Step 6: Notify clients const conversationRoom = getBodyshopConversationRoom({ bodyshopId: conversation.bodyshop.id, conversationId: conversation.id @@ -179,6 +147,7 @@ const receive = async (req, res) => { msid: message.sid }; + const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id); ioRedis.to(broadcastRoom).emit("new-message-summary", { ...commonPayload, existingConversation: !!existingConversation, @@ -194,7 +163,7 @@ const receive = async (req, res) => { summary: false }); - // Step 6: Send FCM notification + // Step 7: Send FCM notification const fcmresp = await admin.messaging().send({ topic: `${message.conversation.bodyshop.imexshopid}-messaging`, notification: { @@ -220,10 +189,51 @@ const receive = async (req, res) => { res.status(200).send(""); } 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 = { receive }; diff --git a/server/sms/send.js b/server/sms/send.js index bc0a95da9..81b2d4d6d 100644 --- a/server/sms/send.js +++ b/server/sms/send.js @@ -1,21 +1,16 @@ const twilio = require("twilio"); const { phone } = require("phone"); -const { INSERT_MESSAGE } = require("../graphql-client/queries"); -const logger = require("../utils/logger"); +const { INSERT_MESSAGE, GET_PHONE_NUMBER_CONSENT } = require("../graphql-client/queries"); const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY); const gqlClient = require("../graphql-client/graphql-client").client; -/** - * Send an outbound SMS message - * @param req - * @param res - * @returns {Promise} - */ const send = async (req, res) => { - const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body; + const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid, bodyshopid } = req.body; const { ioRedis, - ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } + logger, + ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }, + sessionUtils: { getBodyshopFromRedis } } = req; logger.log("sms-outbound", "DEBUG", req.user.email, null, { @@ -26,11 +21,11 @@ const send = async (req, res) => { conversationid, isoutbound: true, userid: req.user.email, - image: req.body.selectedMedia.length > 0, - image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] + image: selectedMedia.length > 0, + 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 || !bodyshopid) { logger.log("sms-outbound-error", "ERROR", req.user.email, null, { type: "missing-parameters", messagingServiceSid, @@ -39,14 +34,38 @@ const send = async (req, res) => { conversationid, isoutbound: true, userid: req.user.email, - image: req.body.selectedMedia.length > 0, - image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] + image: selectedMedia.length > 0, + image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] }); res.status(400).json({ success: false, message: "Missing required parameter(s)." }); return; } 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({ body, messagingServiceSid, @@ -60,8 +79,8 @@ const send = async (req, res) => { conversationid, isoutbound: true, userid: req.user.email, - image: req.body.selectedMedia.length > 0, - image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] + image: selectedMedia.length > 0, + image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] }; try { From 8ee52598e80a1ca68b2961e6bf622fe30bb6bd7d Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 21 May 2025 14:32:35 -0400 Subject: [PATCH 3/5] feature/IO-3182-Phone-Number-Consent - Checkpoint --- client/package-lock.json | 104 ++++---- client/package.json | 6 +- .../chat-affix/chat-affix.container.jsx | 57 +---- .../chat-conversation-list.component.jsx | 81 ++---- .../chat-send-message.component.jsx | 15 +- .../phone-number-consent.component.jsx | 231 +----------------- .../shop-info/shop-info.consent.component.jsx | 50 +--- client/src/graphql/bodyshop.queries.js | 10 - client/src/redux/user/user.actions.js | 5 - client/src/redux/user/user.reducer.js | 9 +- client/src/redux/user/user.types.js | 3 +- client/src/translations/en_us/common.json | 15 +- hasura/metadata/tables.yaml | 96 +------- .../down.sql | 3 + .../up.sql | 1 + .../down.sql | 2 + .../up.sql | 1 + .../down.sql | 3 + .../up.sql | 1 + .../down.sql | 1 + .../up.sql | 1 + .../down.sql | 2 + .../up.sql | 1 + package-lock.json | 8 +- package.json | 2 +- server/graphql-client/queries.js | 130 ---------- server/routes/smsRoutes.js | 3 - server/sms/consent.js | 215 ---------------- server/sms/receive.js | 36 +-- server/sms/send.js | 26 +- server/sms/status.js | 1 + 31 files changed, 128 insertions(+), 991 deletions(-) create mode 100644 hasura/migrations/1747850002182_drop_table_public_phone_number_consent_history/down.sql create mode 100644 hasura/migrations/1747850002182_drop_table_public_phone_number_consent_history/up.sql create mode 100644 hasura/migrations/1747850205206_alter_table_public_phone_number_consent_drop_column_consent_status/down.sql create mode 100644 hasura/migrations/1747850205206_alter_table_public_phone_number_consent_drop_column_consent_status/up.sql create mode 100644 hasura/migrations/1747850221535_alter_table_public_phone_number_consent_drop_column_consent_updated_at/down.sql create mode 100644 hasura/migrations/1747850221535_alter_table_public_phone_number_consent_drop_column_consent_updated_at/up.sql create mode 100644 hasura/migrations/1747850386584_rename_table_public_phone_number_consent/down.sql create mode 100644 hasura/migrations/1747850386584_rename_table_public_phone_number_consent/up.sql create mode 100644 hasura/migrations/1747850458206_alter_table_public_bodyshops_drop_column_enforce_sms_consent/down.sql create mode 100644 hasura/migrations/1747850458206_alter_table_public_bodyshops_drop_column_enforce_sms_consent/up.sql delete mode 100644 server/sms/consent.js diff --git a/client/package-lock.json b/client/package-lock.json index 2939dbd7c..12ba5dfb3 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -21,8 +21,8 @@ "@jsreport/browser-client": "^3.1.0", "@reduxjs/toolkit": "^2.8.2", "@sentry/cli": "^2.45.0", - "@sentry/react": "^9.21.0", - "@sentry/vite-plugin": "^3.4.0", + "@sentry/react": "^9.22.0", + "@sentry/vite-plugin": "^3.5.0", "@splitsoftware/splitio-react": "^2.1.1", "@tanem/react-nprogress": "^5.0.53", "antd": "^5.25.2", @@ -95,7 +95,7 @@ "@emotion/react": "^11.14.0", "@eslint/js": "^9.27.0", "@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/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -4461,88 +4461,88 @@ "license": "MIT" }, "node_modules/@sentry-internal/browser-utils": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.21.0.tgz", - "integrity": "sha512-/lJ5EVUDbsVsPH/sSXwWBERVtzi4kWYeFLc+u+1zr4NrfDrGnPJ5mVS1VlHwtBmYIIWv8harLP+CReg3nDcXdw==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.22.0.tgz", + "integrity": "sha512-Ou1tBnVxFAIn8i9gvrWzRotNJQYiu3awNXpsFCw6qFwmiKAVPa6b13vCdolhXnrIiuR77jY1LQnKh9hXpoRzsg==", "license": "MIT", "dependencies": { - "@sentry/core": "9.21.0" + "@sentry/core": "9.22.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/feedback": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.21.0.tgz", - "integrity": "sha512-Z234NgcWolFpmztCh+9smC6WlO8By5t4KucHNfYSQ0xQYQCxPL5iChj3JpF4dwv+qCYXhDFLQFQbK0U3Px056g==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.22.0.tgz", + "integrity": "sha512-zgMVkoC61fgi41zLcSZA59vOtKxcLrKBo1ECYhPD1hxEaneNqY5fhXDwlQBw96P5l2yqkgfX6YZtSdU4ejI9yA==", "license": "MIT", "dependencies": { - "@sentry/core": "9.21.0" + "@sentry/core": "9.22.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.21.0.tgz", - "integrity": "sha512-7mq3Bsp8EJa3YTIYgmWfNgJdvbeaAJ6VYsqi0yxR/vNGxY3qH+PLlv+ZOEXI2U0CL6vhqFPbqmxiUOCuAjnpGg==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.22.0.tgz", + "integrity": "sha512-9GOycoKbrclcRXfcbNV8svbmAsOS5R4wXBQmKF4pFLkmFA/lJv9kdZSNYkRvkrxdNfbMIJXP+DV9EqTZcryXig==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "9.21.0", - "@sentry/core": "9.21.0" + "@sentry-internal/browser-utils": "9.22.0", + "@sentry/core": "9.22.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.21.0.tgz", - "integrity": "sha512-4tHiNil8qXphaql2YXLGA/wlm0hxaadrh7x8/KErn1iy3vJpn7t/Kka5uug7c2UWhtveS6dgGmqjSkDxM5h9bA==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.22.0.tgz", + "integrity": "sha512-EcG9IMSEalFe49kowBTJObWjof/iHteDwpyuAszsFDdQUYATrVUtwpwN7o52vDYWJud4arhjrQnMamIGxa79eQ==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "9.21.0", - "@sentry/core": "9.21.0" + "@sentry-internal/replay": "9.22.0", + "@sentry/core": "9.22.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/babel-plugin-component-annotate": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.4.0.tgz", - "integrity": "sha512-tSzfc3aE7m0PM0Aj7HBDet5llH9AB9oc+tBQ8AvOqUSnWodLrNCuWeQszJ7mIBovD3figgCU3h0cvI6U5cDtsg==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.5.0.tgz", + "integrity": "sha512-s2go8w03CDHbF9luFGtBHKJp4cSpsQzNVqgIa9Pfa4wnjipvrK6CxVT4icpLA3YO6kg5u622Yoa5GF3cJdippw==", "license": "MIT", "engines": { "node": ">= 14" } }, "node_modules/@sentry/browser": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.21.0.tgz", - "integrity": "sha512-NF0G104JRP2TZ2hpMHElO4bEEUdBWknKSh2d0SRyGpJFVfOQG3oRHczXWH08A5InA/lNrS9LEdodUhiFue+F3A==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.22.0.tgz", + "integrity": "sha512-3TeRm74dvX0JdjX0AgkQa+22iUHwHnY+Q6M05NZ+tDeCNHGK/mEBTeqquS1oQX67jWyuvYmG3VV6RJUxtG9Paw==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "9.21.0", - "@sentry-internal/feedback": "9.21.0", - "@sentry-internal/replay": "9.21.0", - "@sentry-internal/replay-canvas": "9.21.0", - "@sentry/core": "9.21.0" + "@sentry-internal/browser-utils": "9.22.0", + "@sentry-internal/feedback": "9.22.0", + "@sentry-internal/replay": "9.22.0", + "@sentry-internal/replay-canvas": "9.22.0", + "@sentry/core": "9.22.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/bundler-plugin-core": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.4.0.tgz", - "integrity": "sha512-X1Q41AsQ6xcT6hB4wYmBDBukndKM/inT4IsR7pdKLi7ICpX2Qq6lisamBAEPCgEvnLpazSFguaiC0uiwMKAdqw==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.5.0.tgz", + "integrity": "sha512-zDzPrhJqAAy2VzV4g540qAZH4qxzisstK2+NIJPZUUKztWRWUV2cMHsyUtdctYgloGkLyGpZJBE3RE6dmP/xqQ==", "license": "MIT", "dependencies": { "@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", "dotenv": "^16.3.1", "find-up": "^5.0.0", @@ -4902,22 +4902,22 @@ } }, "node_modules/@sentry/core": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.21.0.tgz", - "integrity": "sha512-K0a72Evg0fzc52Oe8R8Op5TyUMzORkk4ytt3G24lSnF4hh8NPf0m6VGkEUgQRPj27g2bF6tq9fCNsJILsf1PDA==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.22.0.tgz", + "integrity": "sha512-ixvtKmPF42Y6ckGUbFlB54OWI75H2gO5UYHojO6eXFpS7xO3ZGgV/QH6wb40mWK+0w5XZ0233FuU9VpsuE6mKA==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@sentry/react": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.21.0.tgz", - "integrity": "sha512-RGbyVo4fS7SX2AjEpdRXDo4C4IYIx0zQcI5bSTgySuhxL0JAxohcuSsNWpx48QkJwK/avtmlmCIPKgbvhF16TQ==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.22.0.tgz", + "integrity": "sha512-mI43NnioBYdG5TiXqRlhV1feZs9bnrrl+k5HOHBK7VQtymaXO0fkcsRLZTkdSgLRLMJGasZuvVhq2xK+18QyWQ==", "license": "MIT", "dependencies": { - "@sentry/browser": "9.21.0", - "@sentry/core": "9.21.0", + "@sentry/browser": "9.22.0", + "@sentry/core": "9.22.0", "hoist-non-react-statics": "^3.3.2" }, "engines": { @@ -4928,12 +4928,12 @@ } }, "node_modules/@sentry/vite-plugin": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-3.4.0.tgz", - "integrity": "sha512-pUFBGrKsHuc8K6A7B1wU2nx65n9aIzvTlcHX9yZ1qvjEO0cZFih0JCwu1Fcav/yrtT9RMN44L/ugu/kMBHQhjQ==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-3.5.0.tgz", + "integrity": "sha512-jUnpTdpicG8wefamw7eNo2uO+Q3KCbOAiF76xH4gfNHSW6TN2hBfOtmLu7J+ive4c0Al3+NEHz19bIPR0lkwWg==", "license": "MIT", "dependencies": { - "@sentry/bundler-plugin-core": "3.4.0", + "@sentry/bundler-plugin-core": "3.5.0", "unplugin": "1.0.1" }, "engines": { @@ -4941,13 +4941,13 @@ } }, "node_modules/@sentry/webpack-plugin": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-3.4.0.tgz", - "integrity": "sha512-i+nAxxniJV5ovijojjTF5n+Yj08Xk8my+vm8+oo0C0I7xcnI2gOKft6B0sJOq01CNbo85X5m/3/edL0PKoWE9w==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-3.5.0.tgz", + "integrity": "sha512-xvclj0QY2HyU7uJLzOlHSrZQBDwfnGKJxp8mmlU4L7CwmK+8xMCqlO7tYZoqE4K/wU3c2xpXql70x8qmvNMxzQ==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/bundler-plugin-core": "3.4.0", + "@sentry/bundler-plugin-core": "3.5.0", "unplugin": "1.0.1", "uuid": "^9.0.0" }, diff --git a/client/package.json b/client/package.json index c6bc4f515..40c52b409 100644 --- a/client/package.json +++ b/client/package.json @@ -20,8 +20,8 @@ "@jsreport/browser-client": "^3.1.0", "@reduxjs/toolkit": "^2.8.2", "@sentry/cli": "^2.45.0", - "@sentry/react": "^9.21.0", - "@sentry/vite-plugin": "^3.4.0", + "@sentry/react": "^9.22.0", + "@sentry/vite-plugin": "^3.5.0", "@splitsoftware/splitio-react": "^2.1.1", "@tanem/react-nprogress": "^5.0.53", "antd": "^5.25.2", @@ -135,7 +135,7 @@ "@emotion/react": "^11.14.0", "@eslint/js": "^9.27.0", "@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/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", diff --git a/client/src/components/chat-affix/chat-affix.container.jsx b/client/src/components/chat-affix/chat-affix.container.jsx index 85b06f2e8..b370b221c 100644 --- a/client/src/components/chat-affix/chat-affix.container.jsx +++ b/client/src/components/chat-affix/chat-affix.container.jsx @@ -8,15 +8,12 @@ import ChatPopupComponent from "../chat-popup/chat-popup.component"; import "./chat-affix.styles.scss"; import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers"; import { useSocket } from "../../contexts/SocketIO/useSocket.js"; -import { GET_PHONE_NUMBER_CONSENT } from "../../graphql/consent.queries"; export function ChatAffixContainer({ bodyshop, chatVisible }) { const { t } = useTranslation(); const client = useApolloClient(); const { socket } = useSocket(); - const enforceConsent = bodyshop?.enforce_sms_consent ?? false; - useEffect(() => { if (!bodyshop || !bodyshop.messagingservicesid) return; @@ -41,63 +38,11 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) { if (socket && socket.connected) { 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 () => { - socket.off("consent-changed", handleConsentChanged); unregisterMessagingHandlers({ socket }); }; } - }, [bodyshop, socket, t, client, enforceConsent]); + }, [bodyshop, socket, t, client]); if (!bodyshop || !bodyshop.messagingservicesid) return <>; diff --git a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx index 3bdc9cd28..16d4c0bf1 100644 --- a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx +++ b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx @@ -1,5 +1,5 @@ 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 { Virtuoso } from "react-virtuoso"; import { createStructuredSelector } from "reselect"; @@ -10,61 +10,35 @@ import PhoneFormatter from "../../utils/PhoneFormatter"; import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component"; import _ from "lodash"; 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({ - selectedConversation: selectSelectedConversation, - bodyshop: selectBodyshop + selectedConversation: selectSelectedConversation }); const mapDispatchToProps = (dispatch) => ({ setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId)) }); -function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) { - const { t } = useTranslation(); +function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) { + // That comma is there for a reason, do not remove it const [, forceUpdate] = useState(false); - const enforceConsent = bodyshop?.enforce_sms_consent ?? false; - - 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]); - + // Re-render every minute useEffect(() => { const interval = setInterval(() => { - forceUpdate((prev) => !prev); - }, 60000); - return () => clearInterval(interval); + forceUpdate((prev) => !prev); // Toggle state to trigger re-render + }, 60000); // 1 minute in milliseconds + + return () => clearInterval(interval); // Cleanup on unmount }, []); - const sortedConversationList = useMemo(() => { + // Memoize the sorted conversation list + const sortedConversationList = React.useMemo(() => { return _.orderBy(conversationList, ["updated_at"], ["desc"]); }, [conversationList]); - const renderConversation = (index, t) => { + const renderConversation = (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 = {item.updated_at}; const cardContentLeft = item.job_conversations.length > 0 @@ -86,12 +60,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation, ); - const cardExtra = ( - <> - - {enforceConsent && !isConsented && {t("messaging.labels.no_consent")}} - - ); + const cardExtra = ; const getCardStyle = () => item.id === selectedConversation @@ -104,25 +73,9 @@ function ChatConversationListComponent({ conversationList, selectedConversation, onClick={() => setSelectedConversation(item.id)} className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`} > - -
- {cardContentLeft} -
-
- {cardContentRight} -
+ +
{cardContentLeft}
+
{cardContentRight}
); @@ -132,7 +85,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
renderConversation(index, t)} + itemContent={(index) => renderConversation(index)} style={{ height: "100%", width: "100%" }} />
diff --git a/client/src/components/chat-send-message/chat-send-message.component.jsx b/client/src/components/chat-send-message/chat-send-message.component.jsx index a42946113..aa5b3f035 100644 --- a/client/src/components/chat-send-message/chat-send-message.component.jsx +++ b/client/src/components/chat-send-message/chat-send-message.component.jsx @@ -1,5 +1,5 @@ import { LoadingOutlined, SendOutlined } from "@ant-design/icons"; -import { Input, Spin, Alert } from "antd"; +import { Alert, Input, Spin } from "antd"; import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; 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 { useQuery } from "@apollo/client"; import { GET_PHONE_NUMBER_CONSENT } from "../../graphql/consent.queries"; -import AlertComponent from "../alert/alert.component"; import { phone } from "phone"; const mapStateToProps = createStructuredSelector({ @@ -31,15 +30,13 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi const [selectedMedia, setSelectedMedia] = useState([]); const { t } = useTranslation(); - const enforceConsent = bodyshop?.enforce_sms_consent ?? false; - const normalizedPhone = phone(conversation.phone_num, "CA").phoneNumber.replace(/^\+1/, ""); const { data: consentData } = useQuery(GET_PHONE_NUMBER_CONSENT, { variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone }, - fetchPolicy: "cache-and-network", - skip: !enforceConsent + fetchPolicy: "cache-and-network" }); - const isConsented = enforceConsent ? (consentData?.phone_number_consent?.[0]?.consent_status ?? false) : true; + + const isConsented = consentData?.phone_number_consent?.[0]?.consent_status ?? false; useEffect(() => { inputArea.current.focus(); @@ -72,9 +69,7 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi return (
- {enforceConsent && !isConsented && ( - - )} + {!isConsented && } { - 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 = [ { title: t("consent.phone_number"), @@ -249,15 +37,6 @@ function PhoneNumberConsentList({ bodyshop, currentUser }) { render: (text) => {text}, sorter: (a, b) => a.phone_number.localeCompare(b.phone_number) }, - { - title: t("consent.status"), - dataIndex: "consent_status", - render: (status, record) => ( - - handleSetConsent(record.phone_number, checked)} /> - - ) - }, { title: t("consent.updated_at"), dataIndex: "consent_updated_at", @@ -272,9 +51,7 @@ function PhoneNumberConsentList({ bodyshop, currentUser }) { onSearch={(value) => setSearch(value)} style={{ marginBottom: 16 }} /> - - - +
({ - updateBodyshopEnforceConsent: (enforce_sms_consent) => dispatch(updateBodyshopEnforceConsent(enforce_sms_consent)) -}); +const mapDispatchToProps = (dispatch) => ({}); -function ShopInfoConsentComponent({ bodyshop, updateBodyshopEnforceConsent }) { +function ShopInfoConsentComponent({ bodyshop }) { 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 (
{t("settings.title")} -
- {t("settings.enforce_sms_consent")} - - { - 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} - /> - -
- {enforceConsent && } + {}
); } diff --git a/client/src/graphql/bodyshop.queries.js b/client/src/graphql/bodyshop.queries.js index af16899cf..7faff13a2 100644 --- a/client/src/graphql/bodyshop.queries.js +++ b/client/src/graphql/bodyshop.queries.js @@ -142,7 +142,6 @@ export const QUERY_BODYSHOP = gql` intellipay_config md_ro_guard notification_followers - enforce_sms_consent employee_teams(order_by: { name: asc }, where: { active: { _eq: true } }) { id 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 - } - } -`; diff --git a/client/src/redux/user/user.actions.js b/client/src/redux/user/user.actions.js index 125aab415..01ba22534 100644 --- a/client/src/redux/user/user.actions.js +++ b/client/src/redux/user/user.actions.js @@ -123,8 +123,3 @@ export const setImexShopId = (imexshopid) => ({ type: UserActionTypes.SET_IMEX_SHOP_ID, payload: imexshopid }); - -export const updateBodyshopEnforceConsent = (enforce_sms_consent) => ({ - type: UserActionTypes.UPDATE_BODYSHOP_ENFORCE_CONSENT, - payload: enforce_sms_consent -}); diff --git a/client/src/redux/user/user.reducer.js b/client/src/redux/user/user.reducer.js index a72d4f068..eebb32433 100644 --- a/client/src/redux/user/user.reducer.js +++ b/client/src/redux/user/user.reducer.js @@ -125,14 +125,7 @@ const userReducer = (state = INITIAL_STATE, action) => { ...state, imexshopid: action.payload }; - case UserActionTypes.UPDATE_BODYSHOP_ENFORCE_CONSENT: - return { - ...state, - bodyshop: { - ...state.bodyshop, - enforce_sms_consent: action.payload - } - }; + default: return state; } diff --git a/client/src/redux/user/user.types.js b/client/src/redux/user/user.types.js index ff21dbb5a..d9cd6fe62 100644 --- a/client/src/redux/user/user.types.js +++ b/client/src/redux/user/user.types.js @@ -33,7 +33,6 @@ const UserActionTypes = { CHECK_ACTION_CODE_FAILURE: "CHECK_ACTION_CODE_FAILURE", SET_CURRENT_EULA: "SET_CURRENT_EULA", EULA_ACCEPTED: "EULA_ACCEPTED", - SET_IMEX_SHOP_ID: "SET_IMEX_SHOP_ID", - UPDATE_BODYSHOP_ENFORCE_CONSENT: "UPDATE_BODYSHOP_ENFORCE_CONSENT" + SET_IMEX_SHOP_ID: "SET_IMEX_SHOP_ID" }; export default UserActionTypes; diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index e753871c4..5c4e9cd85 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -656,6 +656,7 @@ } }, "labels": { + "consent_settings": "Consent Settings", "2tiername": "Name => RO", "2tiersetup": "2 Tier Setup", "2tiersource": "Source => RO", @@ -2377,7 +2378,8 @@ "errors": { "invalidphone": "The phone number is invalid. Unable to open 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": { "addlabel": "Add a label to this conversation.", @@ -2393,7 +2395,8 @@ "selectmedia": "Select Media", "sentby": "Sent by {{by}} at {{time}}", "typeamessage": "Send a message...", - "unarchive": "Unarchive" + "unarchive": "Unarchive", + "no_consent": "No Consent" }, "render": { "conversation_list": "Conversation List" @@ -3862,6 +3865,14 @@ "validation": { "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" } } } diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index fd83ecbc4..025166032 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -957,7 +957,6 @@ - enforce_conversion_category - enforce_conversion_csr - enforce_referral - - enforce_sms_consent - entegral_configuration - entegral_id - features @@ -1068,7 +1067,6 @@ - enforce_conversion_category - enforce_conversion_csr - enforce_referral - - enforce_sms_consent - federal_tax_id - id - inhousevendorid @@ -5864,104 +5862,12 @@ url: '{{$base_url}}/opensearch' version: 2 - table: - name: phone_number_consent + name: phone_number_opt_out schema: public object_relationships: - name: bodyshop using: 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: name: phonebook schema: public diff --git a/hasura/migrations/1747850002182_drop_table_public_phone_number_consent_history/down.sql b/hasura/migrations/1747850002182_drop_table_public_phone_number_consent_history/down.sql new file mode 100644 index 000000000..8b7230c77 --- /dev/null +++ b/hasura/migrations/1747850002182_drop_table_public_phone_number_consent_history/down.sql @@ -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"; diff --git a/hasura/migrations/1747850002182_drop_table_public_phone_number_consent_history/up.sql b/hasura/migrations/1747850002182_drop_table_public_phone_number_consent_history/up.sql new file mode 100644 index 000000000..1093c487f --- /dev/null +++ b/hasura/migrations/1747850002182_drop_table_public_phone_number_consent_history/up.sql @@ -0,0 +1 @@ +DROP table "public"."phone_number_consent_history"; diff --git a/hasura/migrations/1747850205206_alter_table_public_phone_number_consent_drop_column_consent_status/down.sql b/hasura/migrations/1747850205206_alter_table_public_phone_number_consent_drop_column_consent_status/down.sql new file mode 100644 index 000000000..c7f137e6f --- /dev/null +++ b/hasura/migrations/1747850205206_alter_table_public_phone_number_consent_drop_column_consent_status/down.sql @@ -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; diff --git a/hasura/migrations/1747850205206_alter_table_public_phone_number_consent_drop_column_consent_status/up.sql b/hasura/migrations/1747850205206_alter_table_public_phone_number_consent_drop_column_consent_status/up.sql new file mode 100644 index 000000000..8daf3ed0d --- /dev/null +++ b/hasura/migrations/1747850205206_alter_table_public_phone_number_consent_drop_column_consent_status/up.sql @@ -0,0 +1 @@ +alter table "public"."phone_number_consent" drop column "consent_status" cascade; diff --git a/hasura/migrations/1747850221535_alter_table_public_phone_number_consent_drop_column_consent_updated_at/down.sql b/hasura/migrations/1747850221535_alter_table_public_phone_number_consent_drop_column_consent_updated_at/down.sql new file mode 100644 index 000000000..74f22f6fb --- /dev/null +++ b/hasura/migrations/1747850221535_alter_table_public_phone_number_consent_drop_column_consent_updated_at/down.sql @@ -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; diff --git a/hasura/migrations/1747850221535_alter_table_public_phone_number_consent_drop_column_consent_updated_at/up.sql b/hasura/migrations/1747850221535_alter_table_public_phone_number_consent_drop_column_consent_updated_at/up.sql new file mode 100644 index 000000000..154b0a1e6 --- /dev/null +++ b/hasura/migrations/1747850221535_alter_table_public_phone_number_consent_drop_column_consent_updated_at/up.sql @@ -0,0 +1 @@ +alter table "public"."phone_number_consent" drop column "consent_updated_at" cascade; diff --git a/hasura/migrations/1747850386584_rename_table_public_phone_number_consent/down.sql b/hasura/migrations/1747850386584_rename_table_public_phone_number_consent/down.sql new file mode 100644 index 000000000..ede0fa6ca --- /dev/null +++ b/hasura/migrations/1747850386584_rename_table_public_phone_number_consent/down.sql @@ -0,0 +1 @@ +alter table "public"."phone_number_opt_out" rename to "phone_number_consent"; diff --git a/hasura/migrations/1747850386584_rename_table_public_phone_number_consent/up.sql b/hasura/migrations/1747850386584_rename_table_public_phone_number_consent/up.sql new file mode 100644 index 000000000..592a8c243 --- /dev/null +++ b/hasura/migrations/1747850386584_rename_table_public_phone_number_consent/up.sql @@ -0,0 +1 @@ +alter table "public"."phone_number_consent" rename to "phone_number_opt_out"; diff --git a/hasura/migrations/1747850458206_alter_table_public_bodyshops_drop_column_enforce_sms_consent/down.sql b/hasura/migrations/1747850458206_alter_table_public_bodyshops_drop_column_enforce_sms_consent/down.sql new file mode 100644 index 000000000..f25ada416 --- /dev/null +++ b/hasura/migrations/1747850458206_alter_table_public_bodyshops_drop_column_enforce_sms_consent/down.sql @@ -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; diff --git a/hasura/migrations/1747850458206_alter_table_public_bodyshops_drop_column_enforce_sms_consent/up.sql b/hasura/migrations/1747850458206_alter_table_public_bodyshops_drop_column_enforce_sms_consent/up.sql new file mode 100644 index 000000000..57b8d6ebd --- /dev/null +++ b/hasura/migrations/1747850458206_alter_table_public_bodyshops_drop_column_enforce_sms_consent/up.sql @@ -0,0 +1 @@ +alter table "public"."bodyshops" drop column "enforce_sms_consent" cascade; diff --git a/package-lock.json b/package-lock.json index 8e0440f9d..0ee5c9545 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "aws4": "^1.13.2", "axios": "^1.8.4", "better-queue": "^3.8.12", - "bullmq": "^5.52.3", + "bullmq": "^5.53.0", "chart.js": "^4.4.8", "cloudinary": "^2.6.1", "compression": "^1.8.0", @@ -4634,9 +4634,9 @@ } }, "node_modules/bullmq": { - "version": "5.52.3", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.52.3.tgz", - "integrity": "sha512-UaVkg+uSgylYWjD6/d8TVm87SjDVZ5jKwDVh/pJACmStn71aIzOIpGazh2JrkGISgT10Q/lG2I40FhPg0KgNCQ==", + "version": "5.53.0", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.53.0.tgz", + "integrity": "sha512-AbzcwR+9GdgrenolOC9kApF+TkUKZpUCMiFbXgRYw9ivWhOfLCqKeajIptM7NdwhY7cpXgv+QpbweUuQZUxkyA==", "license": "MIT", "dependencies": { "cron-parser": "^4.9.0", diff --git a/package.json b/package.json index a2da483f7..5eec68f9b 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "aws4": "^1.13.2", "axios": "^1.8.4", "better-queue": "^3.8.12", - "bullmq": "^5.52.3", + "bullmq": "^5.53.0", "chart.js": "^4.4.8", "cloudinary": "^2.6.1", "compression": "^1.8.0", diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 84e20db6f..d61d814b9 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2805,7 +2805,6 @@ exports.GET_BODYSHOP_BY_ID = ` intellipay_config state 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 - } - } - } -`; diff --git a/server/routes/smsRoutes.js b/server/routes/smsRoutes.js index 917699aaf..bb23d24e8 100644 --- a/server/routes/smsRoutes.js +++ b/server/routes/smsRoutes.js @@ -5,7 +5,6 @@ const { receive } = require("../sms/receive"); const { send } = require("../sms/send"); const { status, markConversationRead } = require("../sms/status"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); -const { setConsent, bulkSetConsent } = require("../sms/consent"); // Twilio Webhook Middleware for 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("/status", twilioWebhookMiddleware, status); router.post("/markConversationRead", validateFirebaseIdTokenMiddleware, markConversationRead); -router.post("/setConsent", validateFirebaseIdTokenMiddleware, setConsent); -router.post("/bulkSetConsent", validateFirebaseIdTokenMiddleware, bulkSetConsent); module.exports = router; diff --git a/server/sms/consent.js b/server/sms/consent.js deleted file mode 100644 index d7a997423..000000000 --- a/server/sms/consent.js +++ /dev/null @@ -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 -}; diff --git a/server/sms/receive.js b/server/sms/receive.js index 59c4b1b32..ad539dbe4 100644 --- a/server/sms/receive.js +++ b/server/sms/receive.js @@ -3,8 +3,7 @@ const { FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, UNARCHIVE_CONVERSATION, CREATE_CONVERSATION, - INSERT_MESSAGE, - SET_PHONE_NUMBER_CONSENT + INSERT_MESSAGE } = require("../graphql-client/queries"); const { phone } = require("phone"); const { admin } = require("../firebase/firebase-handler"); @@ -17,11 +16,11 @@ const InstanceManager = require("../utils/instanceMgr").default; * @returns {Promise<*>} */ const receive = async (req, res) => { + console.dir(req.body); const { logger, ioRedis, - ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }, - sessionUtils: { getBodyshopFromRedis } + ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } } = req; const loggerData = { @@ -54,35 +53,6 @@ const receive = async (req, res) => { 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 const sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const existingConversation = sortedConversations.length diff --git a/server/sms/send.js b/server/sms/send.js index 81b2d4d6d..aa3c5a84c 100644 --- a/server/sms/send.js +++ b/server/sms/send.js @@ -1,6 +1,6 @@ const twilio = require("twilio"); 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 gqlClient = require("../graphql-client/graphql-client").client; @@ -42,30 +42,6 @@ const send = async (req, res) => { } 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({ body, messagingServiceSid, diff --git a/server/sms/status.js b/server/sms/status.js index 509c76d6b..385dbaa40 100644 --- a/server/sms/status.js +++ b/server/sms/status.js @@ -9,6 +9,7 @@ const logger = require("../utils/logger"); * @returns {Promise<*>} */ const status = async (req, res) => { + console.dir(req.body); const { SmsSid, SmsStatus } = req.body; const { ioRedis, From 8c8c68867dc603b0ff7964ec635307ae1395c945 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 21 May 2025 14:39:17 -0400 Subject: [PATCH 4/5] feature/IO-3182-Phone-Number-Consent - Checkpoint --- server/routes/smsRoutes.js | 1 + server/sms/receive.js | 2 +- server/sms/send.js | 4 ++-- server/sms/status.js | 1 - 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/routes/smsRoutes.js b/server/routes/smsRoutes.js index bb23d24e8..c09cc1632 100644 --- a/server/routes/smsRoutes.js +++ b/server/routes/smsRoutes.js @@ -7,6 +7,7 @@ const { status, markConversationRead } = require("../sms/status"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); // Twilio Webhook Middleware for production +// TODO: This is never actually doing anything, we should probably verify const twilioWebhookMiddleware = twilio.webhook({ validate: process.env.NODE_ENV === "PRODUCTION" }); router.post("/receive", twilioWebhookMiddleware, receive); diff --git a/server/sms/receive.js b/server/sms/receive.js index ad539dbe4..bf5262b25 100644 --- a/server/sms/receive.js +++ b/server/sms/receive.js @@ -16,7 +16,6 @@ const InstanceManager = require("../utils/instanceMgr").default; * @returns {Promise<*>} */ const receive = async (req, res) => { - console.dir(req.body); const { logger, ioRedis, @@ -118,6 +117,7 @@ const receive = async (req, res) => { }; const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id); + ioRedis.to(broadcastRoom).emit("new-message-summary", { ...commonPayload, existingConversation: !!existingConversation, diff --git a/server/sms/send.js b/server/sms/send.js index aa3c5a84c..c5e897a98 100644 --- a/server/sms/send.js +++ b/server/sms/send.js @@ -5,7 +5,7 @@ const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY const gqlClient = require("../graphql-client/graphql-client").client; const send = async (req, res) => { - const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid, bodyshopid } = req.body; + const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body; const { ioRedis, logger, @@ -25,7 +25,7 @@ const send = async (req, res) => { image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] }); - if (!to || !messagingServiceSid || (!body && selectedMedia.length === 0) || !conversationid || !bodyshopid) { + if (!to || !messagingServiceSid || (!body && selectedMedia.length === 0) || !conversationid) { logger.log("sms-outbound-error", "ERROR", req.user.email, null, { type: "missing-parameters", messagingServiceSid, diff --git a/server/sms/status.js b/server/sms/status.js index 385dbaa40..509c76d6b 100644 --- a/server/sms/status.js +++ b/server/sms/status.js @@ -9,7 +9,6 @@ const logger = require("../utils/logger"); * @returns {Promise<*>} */ const status = async (req, res) => { - console.dir(req.body); const { SmsSid, SmsStatus } = req.body; const { ioRedis, From 6afa50332b8be3ebc3a1b10568cdbc25e1efbe13 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 21 May 2025 15:03:02 -0400 Subject: [PATCH 5/5] feature/IO-3182-Phone-Number-Consent - Checkpoint --- .../chat-conversation-list.component.jsx | 83 +++++++++++++++---- .../chat-send-message.component.jsx | 16 ++-- .../phone-number-consent.component.jsx | 5 +- client/src/graphql/consent.queries.js | 48 ----------- .../graphql/phone-number-opt-out.queries.js | 28 +++++++ client/src/translations/en_us/common.json | 4 +- server/sms/send.js | 9 +- 7 files changed, 114 insertions(+), 79 deletions(-) delete mode 100644 client/src/graphql/consent.queries.js create mode 100644 client/src/graphql/phone-number-opt-out.queries.js diff --git a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx index 16d4c0bf1..70bfc5b5d 100644 --- a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx +++ b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx @@ -1,5 +1,5 @@ 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 { Virtuoso } from "react-virtuoso"; import { createStructuredSelector } from "reselect"; @@ -10,35 +10,63 @@ import PhoneFormatter from "../../utils/PhoneFormatter"; import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component"; import _ from "lodash"; 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({ - selectedConversation: selectSelectedConversation + selectedConversation: selectSelectedConversation, + bodyshop: selectBodyshop }); const mapDispatchToProps = (dispatch) => ({ setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId)) }); -function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) { - // That comma is there for a reason, do not remove it +function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) { + const { t } = useTranslation(); 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(() => { const interval = setInterval(() => { - forceUpdate((prev) => !prev); // Toggle state to trigger re-render - }, 60000); // 1 minute in milliseconds - - return () => clearInterval(interval); // Cleanup on unmount + forceUpdate((prev) => !prev); + }, 60000); + return () => clearInterval(interval); }, []); - // Memoize the sorted conversation list - const sortedConversationList = React.useMemo(() => { + const sortedConversationList = useMemo(() => { return _.orderBy(conversationList, ["updated_at"], ["desc"]); }, [conversationList]); - const renderConversation = (index) => { + const renderConversation = (index, t) => { 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 = {item.updated_at}; const cardContentLeft = item.job_conversations.length > 0 @@ -60,7 +88,12 @@ function ChatConversationListComponent({ conversationList, selectedConversation, ); - const cardExtra = ; + const cardExtra = ( + <> + + {hasOptOutEntry && !isOptedOut && {t("messaging.labels.no_consent")}} + + ); const getCardStyle = () => item.id === selectedConversation @@ -73,9 +106,25 @@ function ChatConversationListComponent({ conversationList, selectedConversation, onClick={() => setSelectedConversation(item.id)} className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`} > - -
{cardContentLeft}
-
{cardContentRight}
+ +
+ {cardContentLeft} +
+
+ {cardContentRight} +
); @@ -85,7 +134,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
renderConversation(index)} + itemContent={(index) => renderConversation(index, t)} style={{ height: "100%", width: "100%" }} />
diff --git a/client/src/components/chat-send-message/chat-send-message.component.jsx b/client/src/components/chat-send-message/chat-send-message.component.jsx index aa5b3f035..798532a3e 100644 --- a/client/src/components/chat-send-message/chat-send-message.component.jsx +++ b/client/src/components/chat-send-message/chat-send-message.component.jsx @@ -11,8 +11,8 @@ import { selectBodyshop } from "../../redux/user/user.selectors"; import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component"; import ChatPresetsComponent from "../chat-presets/chat-presets.component"; import { useQuery } from "@apollo/client"; -import { GET_PHONE_NUMBER_CONSENT } from "../../graphql/consent.queries"; import { phone } from "phone"; +import { GET_PHONE_NUMBER_OPT_OUT } from "../../graphql/phone-number-opt-out.queries"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -31,12 +31,12 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi const { t } = useTranslation(); const normalizedPhone = phone(conversation.phone_num, "CA").phoneNumber.replace(/^\+1/, ""); - const { data: consentData } = useQuery(GET_PHONE_NUMBER_CONSENT, { + const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUT, { variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone }, fetchPolicy: "cache-and-network" }); - const isConsented = consentData?.phone_number_consent?.[0]?.consent_status ?? false; + const isOptedOut = !!optOutData?.phone_number_opt_out?.[0]; useEffect(() => { inputArea.current.focus(); @@ -45,7 +45,7 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi const handleEnter = () => { const selectedImages = selectedMedia.filter((i) => i.isSelected); if ((message === "" || !message) && selectedImages.length === 0) return; - if (!isConsented) return; + if (isOptedOut) return; // Prevent sending if phone number is opted out logImEXEvent("messaging_send_message"); if (selectedImages.length < 11) { @@ -69,7 +69,7 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi return (
- {!isConsented && } + {isOptedOut && } setMessage(e.target.value)} onPressEnter={(event) => { event.preventDefault(); - if (!event.shiftKey && isConsented) handleEnter(); + if (!event.shiftKey && !isOptedOut) handleEnter(); }} /> RO", "2tiersetup": "2 Tier Setup", "2tiersource": "Source => RO", @@ -3872,7 +3872,7 @@ "updated_at": "Last Updated" }, "settings": { - "title": "Phone Number Opt-Out list" + "title": "Phone Number Opt-Out List" } } } diff --git a/server/sms/send.js b/server/sms/send.js index c5e897a98..fdd2c81ae 100644 --- a/server/sms/send.js +++ b/server/sms/send.js @@ -4,13 +4,18 @@ const { INSERT_MESSAGE } = require("../graphql-client/queries"); const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY); const gqlClient = require("../graphql-client/graphql-client").client; +/** + * Send an outbound SMS message + * @param req + * @param res + * @returns {Promise} + */ const send = async (req, res) => { const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body; const { ioRedis, logger, - ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }, - sessionUtils: { getBodyshopFromRedis } + ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } } = req; logger.log("sms-outbound", "DEBUG", req.user.email, null, {