feature/IO-3182-Phone-Number-Consent - Checkpoint
This commit is contained in:
@@ -8,13 +8,15 @@ import ChatPopupComponent from "../chat-popup/chat-popup.component";
|
|||||||
import "./chat-affix.styles.scss";
|
import "./chat-affix.styles.scss";
|
||||||
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
|
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||||
import { GET_PHONE_NUMBER_CONSENT } from "../../graphql/consent.queries.js";
|
import { GET_PHONE_NUMBER_CONSENT } from "../../graphql/consent.queries";
|
||||||
|
|
||||||
export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
const { socket } = useSocket();
|
const { socket } = useSocket();
|
||||||
|
|
||||||
|
const enforceConsent = bodyshop?.enforce_sms_consent ?? false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bodyshop || !bodyshop.messagingservicesid) return;
|
if (!bodyshop || !bodyshop.messagingservicesid) return;
|
||||||
|
|
||||||
@@ -39,45 +41,52 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
|||||||
if (socket && socket.connected) {
|
if (socket && socket.connected) {
|
||||||
registerMessagingHandlers({ socket, client });
|
registerMessagingHandlers({ socket, client });
|
||||||
|
|
||||||
// Handle consent-changed events
|
// Handle consent-changed events only if enforce_sms_consent is true
|
||||||
const handleConsentChanged = ({ bodyshopId, phone_number, consent_status }) => {
|
const handleConsentChanged = ({ bodyshopId, phone_number, consent_status, reason }) => {
|
||||||
|
if (!enforceConsent || bodyshopId !== bodyshop.id) return;
|
||||||
|
|
||||||
try {
|
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,
|
query: GET_PHONE_NUMBER_CONSENT,
|
||||||
variables: { bodyshopid: bodyshopId, phone_number }
|
variables: { bodyshopid: bodyshopId, phone_number }
|
||||||
},
|
},
|
||||||
(data) => {
|
{
|
||||||
if (!data?.phone_number_consent?.[0]) {
|
phone_number_consent: [updatedConsent]
|
||||||
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()
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log("Cache update in handleConsentChanged:", { phone_number, consent_status, updatedConsent });
|
||||||
} catch (error) {
|
} 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 });
|
unregisterMessagingHandlers({ socket });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [bodyshop, socket, t, client]);
|
}, [bodyshop, socket, t, client, enforceConsent]);
|
||||||
|
|
||||||
if (!bodyshop || !bodyshop.messagingservicesid) return <></>;
|
if (!bodyshop || !bodyshop.messagingservicesid) return <></>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Badge, Card, List, Space, Tag } from "antd";
|
import { Badge, Card, List, Space, Tag } from "antd";
|
||||||
import React, { useEffect, useState } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Virtuoso } from "react-virtuoso";
|
import { Virtuoso } from "react-virtuoso";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -11,35 +11,37 @@ import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-displ
|
|||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import "./chat-conversation-list.styles.scss";
|
import "./chat-conversation-list.styles.scss";
|
||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
import { GET_PHONE_NUMBER_CONSENTS } from "../../graphql/consent.queries.js";
|
import { GET_PHONE_NUMBER_CONSENTS } from "../../graphql/consent.queries";
|
||||||
import { phone } from "phone";
|
import { phone } from "phone";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
selectedConversation: selectSelectedConversation
|
selectedConversation: selectSelectedConversation,
|
||||||
|
bodyshop: selectBodyshop
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId))
|
setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId))
|
||||||
});
|
});
|
||||||
|
|
||||||
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) {
|
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [, forceUpdate] = useState(false);
|
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 phoneNumbers = conversationList.map((item) => phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""));
|
||||||
const { data: consentData, loading: consentLoading } = useQuery(GET_PHONE_NUMBER_CONSENTS, {
|
const { data: consentData, loading: consentLoading } = useQuery(GET_PHONE_NUMBER_CONSENTS, {
|
||||||
variables: {
|
variables: {
|
||||||
bodyshopid: conversationList[0]?.bodyshopid,
|
bodyshopid: conversationList[0]?.bodyshopid,
|
||||||
phone_numbers: phoneNumbers
|
phone_numbers: phoneNumbers
|
||||||
},
|
},
|
||||||
skip: !conversationList.length || !conversationList[0]?.bodyshopid,
|
skip: !enforceConsent || !conversationList.length || !conversationList[0]?.bodyshopid,
|
||||||
fetchPolicy: "cache-and-network"
|
fetchPolicy: "cache-and-network"
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a map of phone number to consent status
|
const consentMap = useMemo(() => {
|
||||||
const consentMap = React.useMemo(() => {
|
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
consentData?.phone_number_consent?.forEach((consent) => {
|
consentData?.phone_number_consent?.forEach((consent) => {
|
||||||
map.set(consent.phone_number, consent.consent_status);
|
map.set(consent.phone_number, consent.consent_status);
|
||||||
@@ -54,14 +56,14 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sortedConversationList = React.useMemo(() => {
|
const sortedConversationList = useMemo(() => {
|
||||||
return _.orderBy(conversationList, ["updated_at"], ["desc"]);
|
return _.orderBy(conversationList, ["updated_at"], ["desc"]);
|
||||||
}, [conversationList]);
|
}, [conversationList]);
|
||||||
|
|
||||||
const renderConversation = (index, t) => {
|
const renderConversation = (index, t) => {
|
||||||
const item = sortedConversationList[index];
|
const item = sortedConversationList[index];
|
||||||
const normalizedPhone = phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
|
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 = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
|
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
|
||||||
const cardContentLeft =
|
const cardContentLeft =
|
||||||
@@ -87,7 +89,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
|
|||||||
const cardExtra = (
|
const cardExtra = (
|
||||||
<>
|
<>
|
||||||
<Badge count={item.messages_aggregate.aggregate.count} />
|
<Badge count={item.messages_aggregate.aggregate.count} />
|
||||||
{!isConsented && <Tag color="red">{t("messaging.labels.no_consent")}</Tag>}
|
{enforceConsent && !isConsented && <Tag color="red">{t("messaging.labels.no_consent")}</Tag>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -103,8 +105,24 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
|
|||||||
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
|
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
|
||||||
>
|
>
|
||||||
<Card style={getCardStyle()} variant={true} size="small" extra={cardExtra} title={cardTitle}>
|
<Card style={getCardStyle()} variant={true} size="small" extra={cardExtra} title={cardTitle}>
|
||||||
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
|
<div
|
||||||
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "70%",
|
||||||
|
textAlign: "left"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cardContentLeft}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "30%",
|
||||||
|
textAlign: "right"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cardContentRight}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LoadingOutlined, SendOutlined } from "@ant-design/icons";
|
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 React, { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
@@ -31,12 +31,15 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
|
|||||||
const [selectedMedia, setSelectedMedia] = useState([]);
|
const [selectedMedia, setSelectedMedia] = useState([]);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const enforceConsent = bodyshop?.enforce_sms_consent ?? false;
|
||||||
|
|
||||||
const normalizedPhone = phone(conversation.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
|
const normalizedPhone = phone(conversation.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
|
||||||
const { data: consentData } = useQuery(GET_PHONE_NUMBER_CONSENT, {
|
const { data: consentData } = useQuery(GET_PHONE_NUMBER_CONSENT, {
|
||||||
variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone },
|
variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone },
|
||||||
fetchPolicy: "cache-and-network"
|
fetchPolicy: "cache-and-network",
|
||||||
|
skip: !enforceConsent
|
||||||
});
|
});
|
||||||
const isConsented = consentData?.phone_number_consent?.[0]?.consent_status ?? false;
|
const isConsented = enforceConsent ? (consentData?.phone_number_consent?.[0]?.consent_status ?? false) : true;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputArea.current.focus();
|
inputArea.current.focus();
|
||||||
@@ -69,8 +72,8 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="imex-flex-row" style={{ width: "100%" }}>
|
<div className="imex-flex-row" style={{ width: "100%" }}>
|
||||||
{!isConsented && (
|
{enforceConsent && !isConsented && (
|
||||||
<AlertComponent message={t("messaging.errors.no_consent")} type="warning" style={{ marginBottom: 8 }} />
|
<Alert message={t("messaging.errors.no_consent")} type="warning" style={{ marginBottom: 8 }} />
|
||||||
)}
|
)}
|
||||||
<ChatPresetsComponent className="imex-flex-row__margin" />
|
<ChatPresetsComponent className="imex-flex-row__margin" />
|
||||||
<ChatMediaSelector
|
<ChatMediaSelector
|
||||||
|
|||||||
@@ -1,73 +1,260 @@
|
|||||||
import { useMutation, useQuery } from "@apollo/client";
|
import { useQuery, useApolloClient } from "@apollo/client";
|
||||||
import { Table, Switch, Input, Tooltip, Upload, Button } from "antd";
|
import { Table, Switch, Input, Tooltip, Upload, Button } from "antd";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
import {
|
import { GET_PHONE_NUMBER_CONSENTS } from "../../graphql/consent.queries";
|
||||||
GET_PHONE_NUMBER_CONSENTS,
|
|
||||||
SET_PHONE_NUMBER_CONSENT,
|
|
||||||
BULK_SET_PHONE_NUMBER_CONSENT
|
|
||||||
} from "../../graphql/consent.queries.js";
|
|
||||||
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
|
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
|
||||||
import { TimeAgoFormatter } from "../../utils/DateFormatter";
|
import { TimeAgoFormatter } from "../../utils/DateFormatter";
|
||||||
import { UploadOutlined } from "@ant-design/icons";
|
import { UploadOutlined } from "@ant-design/icons";
|
||||||
import { phone } from "phone";
|
import { phone } from "phone";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||||
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop,
|
||||||
|
currentUser: selectCurrentUser
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = () => ({});
|
const mapDispatchToProps = () => ({});
|
||||||
|
|
||||||
function PhoneNumberConsentList({ bodyshop }) {
|
function PhoneNumberConsentList({ bodyshop, currentUser }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const { loading, data } = useQuery(GET_PHONE_NUMBER_CONSENTS, {
|
const notification = useNotification();
|
||||||
variables: { bodyshopid: bodyshop.id, search },
|
const { loading, data, refetch } = useQuery(GET_PHONE_NUMBER_CONSENTS, {
|
||||||
|
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined },
|
||||||
fetchPolicy: "network-only"
|
fetchPolicy: "network-only"
|
||||||
});
|
});
|
||||||
const [setConsent] = useMutation(SET_PHONE_NUMBER_CONSENT);
|
const client = useApolloClient();
|
||||||
const [bulkSetConsent] = useMutation(BULK_SET_PHONE_NUMBER_CONSENT);
|
const { socket } = useSocket();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket || !socket.connected) return;
|
||||||
|
|
||||||
|
const handleConsentChanged = ({ bodyshopId, phone_number, consent_status, reason }) => {
|
||||||
|
if (bodyshopId !== bodyshop.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cacheData = client.readQuery({
|
||||||
|
query: GET_PHONE_NUMBER_CONSENTS,
|
||||||
|
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cacheData?.phone_number_consent) {
|
||||||
|
console.warn("No cached data for GET_PHONE_NUMBER_CONSENTS in WebSocket handler");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedConsents = cacheData.phone_number_consent.map((consent) =>
|
||||||
|
consent.phone_number === phone_number
|
||||||
|
? {
|
||||||
|
...consent,
|
||||||
|
consent_status,
|
||||||
|
consent_updated_at: new Date().toISOString(),
|
||||||
|
phone_number_consent_history: [
|
||||||
|
{
|
||||||
|
__typename: "phone_number_consent_history",
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
reason,
|
||||||
|
changed_at: new Date().toISOString(),
|
||||||
|
old_value: consent.consent_status,
|
||||||
|
new_value: consent_status,
|
||||||
|
changed_by: currentUser.email
|
||||||
|
},
|
||||||
|
...(consent.phone_number_consent_history || [])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
: consent
|
||||||
|
);
|
||||||
|
|
||||||
|
client.writeQuery(
|
||||||
|
{
|
||||||
|
query: GET_PHONE_NUMBER_CONSENTS,
|
||||||
|
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phone_number_consent: updatedConsents
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("WebSocket cache update:", { phone_number, consent_status, updatedConsents });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating consent cache (WebSocket):", error.message, error.stack);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("consent-changed", handleConsentChanged);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off("consent-changed", handleConsentChanged);
|
||||||
|
};
|
||||||
|
}, [socket, client, bodyshop.id, search, currentUser.email]);
|
||||||
|
|
||||||
|
const handleSetConsent = async (phone_number, consent_status) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post("/sms/setConsent", {
|
||||||
|
bodyshopid: bodyshop.id,
|
||||||
|
phone_number,
|
||||||
|
consent_status,
|
||||||
|
reason: "Manual override in app",
|
||||||
|
changed_by: currentUser.email
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedConsent = {
|
||||||
|
...response.data.consent,
|
||||||
|
phone_number_consent_history: response.data.consent.phone_number_consent_history.map((history) => ({
|
||||||
|
...history,
|
||||||
|
__typename: "phone_number_consent_history"
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update Apollo cache
|
||||||
|
const cacheData = client.readQuery({
|
||||||
|
query: GET_PHONE_NUMBER_CONSENTS,
|
||||||
|
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined }
|
||||||
|
});
|
||||||
|
|
||||||
|
let cacheUpdated = false;
|
||||||
|
if (cacheData?.phone_number_consent) {
|
||||||
|
const isPhoneNumberInCache = cacheData.phone_number_consent.some(
|
||||||
|
(consent) => consent.phone_number === phone_number
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedConsents = isPhoneNumberInCache
|
||||||
|
? cacheData.phone_number_consent.map((consent) =>
|
||||||
|
consent.phone_number === phone_number ? updatedConsent : consent
|
||||||
|
)
|
||||||
|
: [...cacheData.phone_number_consent, updatedConsent];
|
||||||
|
|
||||||
|
cacheUpdated = client.writeQuery(
|
||||||
|
{
|
||||||
|
query: GET_PHONE_NUMBER_CONSENTS,
|
||||||
|
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phone_number_consent: updatedConsents
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Cache update in handleSetConsent:", {
|
||||||
|
phone_number,
|
||||||
|
consent_status,
|
||||||
|
updatedConsents,
|
||||||
|
search
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("No cached data for GET_PHONE_NUMBER_CONSENTS in handleSetConsent");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always refetch to ensure UI updates
|
||||||
|
await refetch();
|
||||||
|
|
||||||
|
notification.success({
|
||||||
|
message: t("consent.update_success")
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
notification.error({
|
||||||
|
message: t("consent.update_failed")
|
||||||
|
});
|
||||||
|
console.error("Error updating consent:", error.message, error.stack);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkUpload = async (file) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
const text = e.target.result;
|
||||||
|
const lines = text.split("\n").slice(1); // Skip header
|
||||||
|
const consents = lines
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.map((line) => {
|
||||||
|
const [phone_number, consent_status] = line.split(",");
|
||||||
|
return {
|
||||||
|
phone_number: phone(phone_number, "CA").phoneNumber.replace(/^\+1/, ""),
|
||||||
|
consent_status: consent_status.trim().toLowerCase() === "true"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post("/sms/bulkSetConsent", {
|
||||||
|
bodyshopid: bodyshop.id,
|
||||||
|
consents
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedConsents = response.data.consents.map((consent) => ({
|
||||||
|
...consent,
|
||||||
|
phone_number_consent_history: consent.phone_number_consent_history.map((history) => ({
|
||||||
|
...history,
|
||||||
|
__typename: "phone_number_consent_history"
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update Apollo cache
|
||||||
|
const cacheData = client.readQuery({
|
||||||
|
query: GET_PHONE_NUMBER_CONSENTS,
|
||||||
|
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cacheData?.phone_number_consent) {
|
||||||
|
const updatedConsentsMap = new Map(updatedConsents.map((consent) => [consent.phone_number, consent]));
|
||||||
|
|
||||||
|
const mergedConsents = cacheData.phone_number_consent.map((consent) =>
|
||||||
|
updatedConsentsMap.has(consent.phone_number) ? updatedConsentsMap.get(consent.phone_number) : consent
|
||||||
|
);
|
||||||
|
|
||||||
|
updatedConsents.forEach((consent) => {
|
||||||
|
if (!mergedConsents.some((c) => c.phone_number === consent.phone_number)) {
|
||||||
|
mergedConsents.push(consent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.writeQuery(
|
||||||
|
{
|
||||||
|
query: GET_PHONE_NUMBER_CONSENTS,
|
||||||
|
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phone_number_consent: mergedConsents
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Cache update in handleBulkUpload:", { updatedConsents, mergedConsents });
|
||||||
|
} else {
|
||||||
|
console.warn("No cached data for GET_PHONE_NUMBER_CONSENTS in handleBulkUpload");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refetch to ensure UI updates
|
||||||
|
await refetch();
|
||||||
|
} catch (error) {
|
||||||
|
notification.error({
|
||||||
|
message: t("consent.bulk_update_failed")
|
||||||
|
});
|
||||||
|
console.error("Bulk upload failed:", error.message, error.stack);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!bodyshop?.enforce_sms_consent) return null;
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: t("consent.phone_number"),
|
title: t("consent.phone_number"),
|
||||||
dataIndex: "phone_number",
|
dataIndex: "phone_number",
|
||||||
render: (text) => <PhoneNumberFormatter>{text}</PhoneNumberFormatter>
|
render: (text) => <PhoneNumberFormatter>{text}</PhoneNumberFormatter>,
|
||||||
|
sorter: (a, b) => a.phone_number.localeCompare(b.phone_number)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("consent.status"),
|
title: t("consent.status"),
|
||||||
dataIndex: "consent_status",
|
dataIndex: "consent_status",
|
||||||
render: (status, record) => (
|
render: (status, record) => (
|
||||||
<Tooltip title={record.history?.[0]?.reason || "No audit history"}>
|
<Tooltip title={record.phone_number_consent_history?.[0]?.reason || "No audit history"}>
|
||||||
<Switch
|
<Switch checked={status} onChange={(checked) => handleSetConsent(record.phone_number, checked)} />
|
||||||
checked={status}
|
|
||||||
onChange={(checked) =>
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Input.Search
|
<Input.Search
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMutation } from "@apollo/client";
|
import { useMutation } from "@apollo/client";
|
||||||
import { Switch, Typography } from "antd";
|
import { Switch, Typography, Tooltip, message } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
|
import { updateBodyshopEnforceConsent } from "../../redux/user/user.actions";
|
||||||
import { UPDATE_BODYSHOP_ENFORCE_CONSENT } from "../../graphql/bodyshop.queries";
|
import { UPDATE_BODYSHOP_ENFORCE_CONSENT } from "../../graphql/bodyshop.queries";
|
||||||
import PhoneNumberConsentList from "../phone-number-consent/phone-number-consent.component";
|
import PhoneNumberConsentList from "../phone-number-consent/phone-number-consent.component";
|
||||||
|
|
||||||
@@ -11,14 +12,23 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = () => ({});
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
|
updateBodyshopEnforceConsent: (enforce_sms_consent) => dispatch(updateBodyshopEnforceConsent(enforce_sms_consent))
|
||||||
|
});
|
||||||
|
|
||||||
function ShopInfoConsentComponent({ bodyshop }) {
|
function ShopInfoConsentComponent({ bodyshop, updateBodyshopEnforceConsent }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [updateEnforceConsent] = useMutation(UPDATE_BODYSHOP_ENFORCE_CONSENT);
|
const [updateEnforceConsent] = useMutation(UPDATE_BODYSHOP_ENFORCE_CONSENT, {
|
||||||
|
onError: (error) => {
|
||||||
console.dir(bodyshop);
|
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;
|
const enforceConsent = bodyshop?.enforce_sms_consent ?? false;
|
||||||
|
|
||||||
@@ -27,23 +37,29 @@ function ShopInfoConsentComponent({ bodyshop }) {
|
|||||||
<Typography.Title level={4}>{t("settings.title")}</Typography.Title>
|
<Typography.Title level={4}>{t("settings.title")}</Typography.Title>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Typography.Text>{t("settings.enforce_sms_consent")}</Typography.Text>
|
<Typography.Text>{t("settings.enforce_sms_consent")}</Typography.Text>
|
||||||
<Switch
|
<Tooltip
|
||||||
checked={enforceConsent}
|
title={enforceConsent ? t("settings.enforce_sms_consent_warning") : t("settings.enforce_sms_consent_enable")}
|
||||||
onChange={(checked) =>
|
>
|
||||||
updateEnforceConsent({
|
<Switch
|
||||||
variables: { id: bodyshop.id, enforce_sms_consent: checked },
|
checked={enforceConsent}
|
||||||
optimisticResponse: {
|
onChange={(checked) => {
|
||||||
update_bodyshops_by_pk: {
|
if (!checked && enforceConsent) return; // Prevent disabling
|
||||||
__typename: "bodyshops",
|
updateEnforceConsent({
|
||||||
id: bodyshop.id,
|
variables: { id: bodyshop.id, enforce_sms_consent: checked },
|
||||||
enforce_sms_consent: checked
|
optimisticResponse: {
|
||||||
|
update_bodyshops_by_pk: {
|
||||||
|
__typename: "bodyshops",
|
||||||
|
id: bodyshop.id,
|
||||||
|
enforce_sms_consent: checked
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
})
|
}}
|
||||||
}
|
disabled={enforceConsent}
|
||||||
/>
|
/>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<PhoneNumberConsentList bodyshop={bodyshop} />
|
{enforceConsent && <PhoneNumberConsentList bodyshop={bodyshop} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,18 +10,23 @@ export const GET_PHONE_NUMBER_CONSENT = gql`
|
|||||||
created_at
|
created_at
|
||||||
updated_at
|
updated_at
|
||||||
consent_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
|
reason
|
||||||
|
changed_at
|
||||||
|
old_value
|
||||||
|
new_value
|
||||||
|
changed_by
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const GET_PHONE_NUMBER_CONSENTS = gql`
|
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(
|
phone_number_consent(
|
||||||
where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _in: $phone_numbers } }
|
where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _ilike: $search } }
|
||||||
order_by: { consent_updated_at: desc }
|
order_by: [{ phone_number: asc }, { consent_updated_at: desc }]
|
||||||
) {
|
) {
|
||||||
id
|
id
|
||||||
bodyshopid
|
bodyshopid
|
||||||
@@ -30,60 +35,13 @@ export const GET_PHONE_NUMBER_CONSENTS = gql`
|
|||||||
created_at
|
created_at
|
||||||
updated_at
|
updated_at
|
||||||
consent_updated_at
|
consent_updated_at
|
||||||
history(order_by: { changed_at: desc }, limit: 1) {
|
phone_number_consent_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
|
id
|
||||||
bodyshopid
|
reason
|
||||||
phone_number
|
changed_at
|
||||||
consent_status
|
old_value
|
||||||
consent_updated_at
|
new_value
|
||||||
|
changed_by
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,3 +123,8 @@ export const setImexShopId = (imexshopid) => ({
|
|||||||
type: UserActionTypes.SET_IMEX_SHOP_ID,
|
type: UserActionTypes.SET_IMEX_SHOP_ID,
|
||||||
payload: imexshopid
|
payload: imexshopid
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const updateBodyshopEnforceConsent = (enforce_sms_consent) => ({
|
||||||
|
type: UserActionTypes.UPDATE_BODYSHOP_ENFORCE_CONSENT,
|
||||||
|
payload: enforce_sms_consent
|
||||||
|
});
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ const userReducer = (state = INITIAL_STATE, action) => {
|
|||||||
...action.payload //Spread current user details in.
|
...action.payload //Spread current user details in.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
case UserActionTypes.SET_SHOP_DETAILS:
|
case UserActionTypes.SET_SHOP_DETAILS:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -126,6 +125,14 @@ const userReducer = (state = INITIAL_STATE, action) => {
|
|||||||
...state,
|
...state,
|
||||||
imexshopid: action.payload
|
imexshopid: action.payload
|
||||||
};
|
};
|
||||||
|
case UserActionTypes.UPDATE_BODYSHOP_ENFORCE_CONSENT:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
bodyshop: {
|
||||||
|
...state.bodyshop,
|
||||||
|
enforce_sms_consent: action.payload
|
||||||
|
}
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const UserActionTypes = {
|
|||||||
CHECK_ACTION_CODE_FAILURE: "CHECK_ACTION_CODE_FAILURE",
|
CHECK_ACTION_CODE_FAILURE: "CHECK_ACTION_CODE_FAILURE",
|
||||||
SET_CURRENT_EULA: "SET_CURRENT_EULA",
|
SET_CURRENT_EULA: "SET_CURRENT_EULA",
|
||||||
EULA_ACCEPTED: "EULA_ACCEPTED",
|
EULA_ACCEPTED: "EULA_ACCEPTED",
|
||||||
SET_IMEX_SHOP_ID: "SET_IMEX_SHOP_ID"
|
SET_IMEX_SHOP_ID: "SET_IMEX_SHOP_ID",
|
||||||
|
UPDATE_BODYSHOP_ENFORCE_CONSENT: "UPDATE_BODYSHOP_ENFORCE_CONSENT"
|
||||||
};
|
};
|
||||||
export default UserActionTypes;
|
export default UserActionTypes;
|
||||||
|
|||||||
@@ -5871,7 +5871,7 @@
|
|||||||
using:
|
using:
|
||||||
foreign_key_constraint_on: bodyshopid
|
foreign_key_constraint_on: bodyshopid
|
||||||
array_relationships:
|
array_relationships:
|
||||||
- name: phone_number_consent_histories
|
- name: phone_number_consent_history
|
||||||
using:
|
using:
|
||||||
foreign_key_constraint_on:
|
foreign_key_constraint_on:
|
||||||
column: phone_number_consent_id
|
column: phone_number_consent_id
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
alter table "public"."phone_number_consent_history" alter column "old_value" set not null;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
alter table "public"."phone_number_consent_history" alter column "old_value" drop not null;
|
||||||
@@ -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 = `
|
exports.GET_PHONE_NUMBER_CONSENT = `
|
||||||
query GET_PHONE_NUMBER_CONSENT($bodyshopid: uuid!, $phone_number: String!) {
|
query GET_PHONE_NUMBER_CONSENT($bodyshopid: uuid!, $phone_number: String!) {
|
||||||
phone_number_consent(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
|
phone_number_consent(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
|
||||||
@@ -2981,7 +2981,7 @@ exports.GET_PHONE_NUMBER_CONSENT = `
|
|||||||
created_at
|
created_at
|
||||||
updated_at
|
updated_at
|
||||||
consent_updated_at
|
consent_updated_at
|
||||||
history(order_by: { changed_at: desc }) {
|
phone_number_consent_history(order_by: { changed_at: desc }) {
|
||||||
id
|
id
|
||||||
old_value
|
old_value
|
||||||
new_value
|
new_value
|
||||||
@@ -2993,24 +2993,45 @@ exports.GET_PHONE_NUMBER_CONSENT = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Query to get consent history
|
// Query to get consent statuses for multiple phone numbers
|
||||||
exports.GET_PHONE_NUMBER_CONSENT_HISTORY = `
|
exports.GET_PHONE_NUMBER_CONSENTS = `
|
||||||
query GET_PHONE_NUMBER_CONSENT_HISTORY($phone_number_consent_id: uuid!) {
|
query GET_PHONE_NUMBER_CONSENTS($bodyshopid: uuid!, $phone_numbers: [String!]) {
|
||||||
phone_number_consent_history(where: { phone_number_consent_id: { _eq: $phone_number_consent_id } }, order_by: { changed_at: desc }) {
|
phone_number_consent(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _in: $phone_numbers } }) {
|
||||||
id
|
id
|
||||||
phone_number_consent_id
|
bodyshopid
|
||||||
old_value
|
phone_number
|
||||||
new_value
|
consent_status
|
||||||
reason
|
created_at
|
||||||
changed_at
|
updated_at
|
||||||
changed_by
|
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 = `
|
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(
|
insert_phone_number_consent_one(
|
||||||
object: {
|
object: {
|
||||||
bodyshopid: $bodyshopid
|
bodyshopid: $bodyshopid
|
||||||
@@ -3031,24 +3052,10 @@ exports.SET_PHONE_NUMBER_CONSENT = `
|
|||||||
updated_at
|
updated_at
|
||||||
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 = `
|
exports.BULK_SET_PHONE_NUMBER_CONSENT = `
|
||||||
mutation BULK_SET_PHONE_NUMBER_CONSENT($objects: [phone_number_consent_insert_input!]!) {
|
mutation BULK_SET_PHONE_NUMBER_CONSENT($objects: [phone_number_consent_insert_input!]!) {
|
||||||
insert_phone_number_consent(
|
insert_phone_number_consent(
|
||||||
@@ -3064,8 +3071,30 @@ exports.BULK_SET_PHONE_NUMBER_CONSENT = `
|
|||||||
bodyshopid
|
bodyshopid
|
||||||
phone_number
|
phone_number
|
||||||
consent_status
|
consent_status
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
consent_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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ const { receive } = require("../sms/receive");
|
|||||||
const { send } = require("../sms/send");
|
const { send } = require("../sms/send");
|
||||||
const { status, markConversationRead } = require("../sms/status");
|
const { status, markConversationRead } = require("../sms/status");
|
||||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||||
|
const { setConsent, bulkSetConsent } = require("../sms/consent");
|
||||||
|
|
||||||
// Twilio Webhook Middleware for production
|
// Twilio Webhook Middleware for production
|
||||||
// TODO: Look into this because it technically is never validating anything
|
|
||||||
const twilioWebhookMiddleware = twilio.webhook({ validate: process.env.NODE_ENV === "PRODUCTION" });
|
const twilioWebhookMiddleware = twilio.webhook({ validate: process.env.NODE_ENV === "PRODUCTION" });
|
||||||
|
|
||||||
router.post("/receive", twilioWebhookMiddleware, receive);
|
router.post("/receive", twilioWebhookMiddleware, receive);
|
||||||
router.post("/send", validateFirebaseIdTokenMiddleware, send);
|
router.post("/send", validateFirebaseIdTokenMiddleware, send);
|
||||||
router.post("/status", twilioWebhookMiddleware, status);
|
router.post("/status", twilioWebhookMiddleware, status);
|
||||||
router.post("/markConversationRead", validateFirebaseIdTokenMiddleware, markConversationRead);
|
router.post("/markConversationRead", validateFirebaseIdTokenMiddleware, markConversationRead);
|
||||||
|
router.post("/setConsent", validateFirebaseIdTokenMiddleware, setConsent);
|
||||||
|
router.post("/bulkSetConsent", validateFirebaseIdTokenMiddleware, bulkSetConsent);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
215
server/sms/consent.js
Normal file
215
server/sms/consent.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
@@ -8,65 +8,27 @@ const {
|
|||||||
} = require("../graphql-client/queries");
|
} = require("../graphql-client/queries");
|
||||||
const { phone } = require("phone");
|
const { phone } = require("phone");
|
||||||
const { admin } = require("../firebase/firebase-handler");
|
const { admin } = require("../firebase/firebase-handler");
|
||||||
const logger = require("../utils/logger");
|
|
||||||
const InstanceManager = require("../utils/instanceMgr").default;
|
const InstanceManager = require("../utils/instanceMgr").default;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate an array of media URLs from the request body
|
* Receive SMS messages from Twilio and process them
|
||||||
* @param body
|
|
||||||
* @returns {null|*[]}
|
|
||||||
*/
|
|
||||||
const generateMediaArray = (body) => {
|
|
||||||
const { NumMedia } = body;
|
|
||||||
if (parseInt(NumMedia) > 0) {
|
|
||||||
const ret = [];
|
|
||||||
for (let i = 0; i < parseInt(NumMedia); i++) {
|
|
||||||
ret.push(body[`MediaUrl${i}`]);
|
|
||||||
}
|
|
||||||
return ret;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle errors during the message receiving process
|
|
||||||
* @param req
|
|
||||||
* @param error
|
|
||||||
* @param res
|
|
||||||
* @param context
|
|
||||||
*/
|
|
||||||
const handleError = (req, error, res, context) => {
|
|
||||||
logger.log("sms-inbound-error", "ERROR", "api", null, {
|
|
||||||
msid: req.body.SmsMessageSid,
|
|
||||||
text: req.body.Body,
|
|
||||||
image: !!req.body.MediaUrl0,
|
|
||||||
image_path: generateMediaArray(req.body),
|
|
||||||
messagingServiceSid: req.body.MessagingServiceSid,
|
|
||||||
context,
|
|
||||||
error
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(500).json({ error: error.message || "Internal Server Error" });
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Receive an inbound SMS message
|
|
||||||
* @param req
|
* @param req
|
||||||
* @param res
|
* @param res
|
||||||
* @returns {Promise<*>}
|
* @returns {Promise<*>}
|
||||||
*/
|
*/
|
||||||
const receive = async (req, res) => {
|
const receive = async (req, res) => {
|
||||||
const {
|
const {
|
||||||
|
logger,
|
||||||
ioRedis,
|
ioRedis,
|
||||||
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
|
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom },
|
||||||
|
sessionUtils: { getBodyshopFromRedis }
|
||||||
} = req;
|
} = req;
|
||||||
|
|
||||||
const loggerData = {
|
const loggerData = {
|
||||||
msid: req.body.SmsMessageSid,
|
msid: req.body.SmsMessageSid,
|
||||||
text: req.body.Body,
|
text: req.body.Body,
|
||||||
image: !!req.body.MediaUrl0,
|
image: !!req.body.MediaUrl0,
|
||||||
image_path: generateMediaArray(req.body)
|
image_path: generateMediaArray(req.body, logger)
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.log("sms-inbound", "DEBUG", "api", null, loggerData);
|
logger.log("sms-inbound", "DEBUG", "api", null, loggerData);
|
||||||
@@ -92,30 +54,36 @@ const receive = async (req, res) => {
|
|||||||
|
|
||||||
const bodyshop = response.bodyshops[0];
|
const bodyshop = response.bodyshops[0];
|
||||||
|
|
||||||
// Step 2: Handle consent
|
// Step 2: Check enforce_sms_consent
|
||||||
const normalizedPhone = phone(req.body.From, "CA").phoneNumber.replace(/^\+1/, "");
|
const bodyShopData = await getBodyshopFromRedis(bodyshopid);
|
||||||
const isStop = req.body.Body.toUpperCase().includes("STOP");
|
const enforceConsent = bodyShopData?.enforce_sms_consent ?? false;
|
||||||
const consentStatus = isStop ? false : true;
|
|
||||||
const reason = isStop ? "Customer texted STOP" : "Inbound message received";
|
|
||||||
|
|
||||||
const consentResponse = await client.request(SET_PHONE_NUMBER_CONSENT, {
|
// Step 3: Handle consent only if enforce_sms_consent is true
|
||||||
bodyshopid: bodyshop.id,
|
if (enforceConsent) {
|
||||||
phone_number: normalizedPhone,
|
const normalizedPhone = phone(req.body.From, "CA").phoneNumber.replace(/^\+1/, "");
|
||||||
consent_status: consentStatus,
|
const isStop = req.body.Body.toUpperCase().includes("STOP");
|
||||||
reason,
|
const consentStatus = isStop ? false : true;
|
||||||
changed_by: "system"
|
const reason = isStop ? "Customer texted STOP" : "Inbound message received";
|
||||||
});
|
|
||||||
|
|
||||||
// Emit WebSocket event for consent change
|
const consentResponse = await client.request(SET_PHONE_NUMBER_CONSENT, {
|
||||||
const broadcastRoom = getBodyshopRoom(bodyshop.id);
|
bodyshopid: bodyshop.id,
|
||||||
ioRedis.to(broadcastRoom).emit("consent-changed", {
|
phone_number: normalizedPhone,
|
||||||
bodyshopId: bodyshop.id,
|
consent_status: consentStatus,
|
||||||
phone_number: normalizedPhone,
|
reason,
|
||||||
consent_status: consentStatus,
|
changed_by: "system"
|
||||||
reason
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// 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 sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||||
const existingConversation = sortedConversations.length
|
const existingConversation = sortedConversations.length
|
||||||
? sortedConversations[sortedConversations.length - 1]
|
? sortedConversations[sortedConversations.length - 1]
|
||||||
@@ -126,7 +94,7 @@ const receive = async (req, res) => {
|
|||||||
msid: req.body.SmsMessageSid,
|
msid: req.body.SmsMessageSid,
|
||||||
text: req.body.Body,
|
text: req.body.Body,
|
||||||
image: !!req.body.MediaUrl0,
|
image: !!req.body.MediaUrl0,
|
||||||
image_path: generateMediaArray(req.body),
|
image_path: generateMediaArray(req.body, logger),
|
||||||
isoutbound: false,
|
isoutbound: false,
|
||||||
userid: null
|
userid: null
|
||||||
};
|
};
|
||||||
@@ -143,7 +111,7 @@ const receive = async (req, res) => {
|
|||||||
const newConversationResponse = await client.request(CREATE_CONVERSATION, {
|
const newConversationResponse = await client.request(CREATE_CONVERSATION, {
|
||||||
conversation: {
|
conversation: {
|
||||||
bodyshopid: bodyshop.id,
|
bodyshopid: bodyshop.id,
|
||||||
phone_num: normalizedPhone,
|
phone_num: phone(req.body.From).phoneNumber,
|
||||||
archived: false
|
archived: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -153,7 +121,7 @@ const receive = async (req, res) => {
|
|||||||
|
|
||||||
newMessage.conversationid = conversationid;
|
newMessage.conversationid = conversationid;
|
||||||
|
|
||||||
// Step 4: Insert the message
|
// Step 5: Insert the message
|
||||||
const insertresp = await client.request(INSERT_MESSAGE, {
|
const insertresp = await client.request(INSERT_MESSAGE, {
|
||||||
msg: newMessage,
|
msg: newMessage,
|
||||||
conversationid
|
conversationid
|
||||||
@@ -166,7 +134,7 @@ const receive = async (req, res) => {
|
|||||||
throw new Error("Conversation data is missing from the response.");
|
throw new Error("Conversation data is missing from the response.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Notify clients
|
// Step 6: Notify clients
|
||||||
const conversationRoom = getBodyshopConversationRoom({
|
const conversationRoom = getBodyshopConversationRoom({
|
||||||
bodyshopId: conversation.bodyshop.id,
|
bodyshopId: conversation.bodyshop.id,
|
||||||
conversationId: conversation.id
|
conversationId: conversation.id
|
||||||
@@ -179,6 +147,7 @@ const receive = async (req, res) => {
|
|||||||
msid: message.sid
|
msid: message.sid
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id);
|
||||||
ioRedis.to(broadcastRoom).emit("new-message-summary", {
|
ioRedis.to(broadcastRoom).emit("new-message-summary", {
|
||||||
...commonPayload,
|
...commonPayload,
|
||||||
existingConversation: !!existingConversation,
|
existingConversation: !!existingConversation,
|
||||||
@@ -194,7 +163,7 @@ const receive = async (req, res) => {
|
|||||||
summary: false
|
summary: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 6: Send FCM notification
|
// Step 7: Send FCM notification
|
||||||
const fcmresp = await admin.messaging().send({
|
const fcmresp = await admin.messaging().send({
|
||||||
topic: `${message.conversation.bodyshop.imexshopid}-messaging`,
|
topic: `${message.conversation.bodyshop.imexshopid}-messaging`,
|
||||||
notification: {
|
notification: {
|
||||||
@@ -220,10 +189,51 @@ const receive = async (req, res) => {
|
|||||||
|
|
||||||
res.status(200).send("");
|
res.status(200).send("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(req, e, res, "RECEIVE_MESSAGE");
|
handleError(req, e, res, "RECEIVE_MESSAGE", logger);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate media array from the request body
|
||||||
|
* @param body
|
||||||
|
* @param logger
|
||||||
|
* @returns {null|*[]}
|
||||||
|
*/
|
||||||
|
const generateMediaArray = (body, logger) => {
|
||||||
|
const { NumMedia } = body;
|
||||||
|
if (parseInt(NumMedia) > 0) {
|
||||||
|
const ret = [];
|
||||||
|
for (let i = 0; i < parseInt(NumMedia); i++) {
|
||||||
|
ret.push(body[`MediaUrl${i}`]);
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle error logging and response
|
||||||
|
* @param req
|
||||||
|
* @param error
|
||||||
|
* @param res
|
||||||
|
* @param context
|
||||||
|
* @param logger
|
||||||
|
*/
|
||||||
|
const handleError = (req, error, res, context, logger) => {
|
||||||
|
logger.log("sms-inbound-error", "ERROR", "api", null, {
|
||||||
|
msid: req.body.SmsMessageSid,
|
||||||
|
text: req.body.Body,
|
||||||
|
image: !!req.body.MediaUrl0,
|
||||||
|
image_path: generateMediaArray(req.body, logger),
|
||||||
|
messagingServiceSid: req.body.MessagingServiceSid,
|
||||||
|
context,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(500).json({ error: error.message || "Internal Server Error" });
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
receive
|
receive
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
const twilio = require("twilio");
|
const twilio = require("twilio");
|
||||||
const { phone } = require("phone");
|
const { phone } = require("phone");
|
||||||
const { INSERT_MESSAGE } = require("../graphql-client/queries");
|
const { INSERT_MESSAGE, GET_PHONE_NUMBER_CONSENT } = require("../graphql-client/queries");
|
||||||
const logger = require("../utils/logger");
|
|
||||||
const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY);
|
const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY);
|
||||||
const gqlClient = require("../graphql-client/graphql-client").client;
|
const gqlClient = require("../graphql-client/graphql-client").client;
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an outbound SMS message
|
|
||||||
* @param req
|
|
||||||
* @param res
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
const send = async (req, res) => {
|
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 {
|
const {
|
||||||
ioRedis,
|
ioRedis,
|
||||||
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
|
logger,
|
||||||
|
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom },
|
||||||
|
sessionUtils: { getBodyshopFromRedis }
|
||||||
} = req;
|
} = req;
|
||||||
|
|
||||||
logger.log("sms-outbound", "DEBUG", req.user.email, null, {
|
logger.log("sms-outbound", "DEBUG", req.user.email, null, {
|
||||||
@@ -26,11 +21,11 @@ const send = async (req, res) => {
|
|||||||
conversationid,
|
conversationid,
|
||||||
isoutbound: true,
|
isoutbound: true,
|
||||||
userid: req.user.email,
|
userid: req.user.email,
|
||||||
image: req.body.selectedMedia.length > 0,
|
image: selectedMedia.length > 0,
|
||||||
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
|
image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!to || !messagingServiceSid || (!body && selectedMedia.length === 0) || !conversationid) {
|
if (!to || !messagingServiceSid || (!body && selectedMedia.length === 0) || !conversationid || !bodyshopid) {
|
||||||
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
|
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
|
||||||
type: "missing-parameters",
|
type: "missing-parameters",
|
||||||
messagingServiceSid,
|
messagingServiceSid,
|
||||||
@@ -39,14 +34,38 @@ const send = async (req, res) => {
|
|||||||
conversationid,
|
conversationid,
|
||||||
isoutbound: true,
|
isoutbound: true,
|
||||||
userid: req.user.email,
|
userid: req.user.email,
|
||||||
image: req.body.selectedMedia.length > 0,
|
image: selectedMedia.length > 0,
|
||||||
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
|
image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
|
||||||
});
|
});
|
||||||
res.status(400).json({ success: false, message: "Missing required parameter(s)." });
|
res.status(400).json({ success: false, message: "Missing required parameter(s)." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Check bodyshop's enforce_sms_consent setting
|
||||||
|
const bodyShopData = await getBodyshopFromRedis(bodyshopid);
|
||||||
|
const enforceConsent = bodyShopData?.enforce_sms_consent ?? false;
|
||||||
|
|
||||||
|
// Check consent only if enforcement is enabled
|
||||||
|
if (enforceConsent) {
|
||||||
|
const normalizedPhone = phone(to, "CA").phoneNumber.replace(/^\+1/, "");
|
||||||
|
const consentResponse = await gqlClient.request(GET_PHONE_NUMBER_CONSENT, {
|
||||||
|
bodyshopid,
|
||||||
|
phone_number: normalizedPhone
|
||||||
|
});
|
||||||
|
if (!consentResponse.phone_number_consent?.length || !consentResponse.phone_number_consent[0].consent_status) {
|
||||||
|
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
|
||||||
|
type: "no-consent",
|
||||||
|
phone_number: normalizedPhone,
|
||||||
|
conversationid
|
||||||
|
});
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "Phone number has not consented to messaging."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const message = await client.messages.create({
|
const message = await client.messages.create({
|
||||||
body,
|
body,
|
||||||
messagingServiceSid,
|
messagingServiceSid,
|
||||||
@@ -60,8 +79,8 @@ const send = async (req, res) => {
|
|||||||
conversationid,
|
conversationid,
|
||||||
isoutbound: true,
|
isoutbound: true,
|
||||||
userid: req.user.email,
|
userid: req.user.email,
|
||||||
image: req.body.selectedMedia.length > 0,
|
image: selectedMedia.length > 0,
|
||||||
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
|
image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user