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 {