feature/IO-3182-Phone-Number-Consent - Checkpoint
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Badge, Card, List, Space, Tag } from "antd";
|
import { Badge, Card, List, Space, Tag } from "antd";
|
||||||
import React, { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Virtuoso } from "react-virtuoso";
|
import { Virtuoso } from "react-virtuoso";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -10,35 +10,63 @@ import PhoneFormatter from "../../utils/PhoneFormatter";
|
|||||||
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
|
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import "./chat-conversation-list.styles.scss";
|
import "./chat-conversation-list.styles.scss";
|
||||||
|
import { useQuery } from "@apollo/client";
|
||||||
|
import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries.js";
|
||||||
|
import { phone } from "phone";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
selectedConversation: selectSelectedConversation
|
selectedConversation: selectSelectedConversation,
|
||||||
|
bodyshop: selectBodyshop
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId))
|
setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId))
|
||||||
});
|
});
|
||||||
|
|
||||||
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) {
|
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) {
|
||||||
// That comma is there for a reason, do not remove it
|
const { t } = useTranslation();
|
||||||
const [, forceUpdate] = useState(false);
|
const [, forceUpdate] = useState(false);
|
||||||
|
|
||||||
// Re-render every minute
|
const phoneNumbers = conversationList.map((item) => phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""));
|
||||||
|
|
||||||
|
const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, {
|
||||||
|
variables: {
|
||||||
|
bodyshopid: bodyshop.id,
|
||||||
|
phone_numbers: phoneNumbers
|
||||||
|
},
|
||||||
|
skip: !conversationList.length,
|
||||||
|
fetchPolicy: "cache-and-network"
|
||||||
|
});
|
||||||
|
|
||||||
|
const optOutMap = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
optOutData?.phone_number_opt_out?.forEach((optOut) => {
|
||||||
|
map.set(optOut.phone_number, true);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [optOutData?.phone_number_opt_out]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
forceUpdate((prev) => !prev); // Toggle state to trigger re-render
|
forceUpdate((prev) => !prev);
|
||||||
}, 60000); // 1 minute in milliseconds
|
}, 60000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
return () => clearInterval(interval); // Cleanup on unmount
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Memoize the sorted conversation list
|
const sortedConversationList = useMemo(() => {
|
||||||
const sortedConversationList = React.useMemo(() => {
|
|
||||||
return _.orderBy(conversationList, ["updated_at"], ["desc"]);
|
return _.orderBy(conversationList, ["updated_at"], ["desc"]);
|
||||||
}, [conversationList]);
|
}, [conversationList]);
|
||||||
|
|
||||||
const renderConversation = (index) => {
|
const renderConversation = (index, t) => {
|
||||||
const item = sortedConversationList[index];
|
const item = sortedConversationList[index];
|
||||||
|
const normalizedPhone = phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
|
||||||
|
// Check if the phone number exists in the consentMap
|
||||||
|
const hasOptOutEntry = optOutMap.has(normalizedPhone);
|
||||||
|
// Only consider it non-consented if it exists and consent_status is false
|
||||||
|
const isOptedOut = hasOptOutEntry ? optOutMap.get(normalizedPhone) : true;
|
||||||
|
|
||||||
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
|
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
|
||||||
const cardContentLeft =
|
const cardContentLeft =
|
||||||
item.job_conversations.length > 0
|
item.job_conversations.length > 0
|
||||||
@@ -60,7 +88,12 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count} />;
|
const cardExtra = (
|
||||||
|
<>
|
||||||
|
<Badge count={item.messages_aggregate.aggregate.count} />
|
||||||
|
{hasOptOutEntry && !isOptedOut && <Tag color="red">{t("messaging.labels.no_consent")}</Tag>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const getCardStyle = () =>
|
const getCardStyle = () =>
|
||||||
item.id === selectedConversation
|
item.id === selectedConversation
|
||||||
@@ -73,9 +106,25 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
|
|||||||
onClick={() => setSelectedConversation(item.id)}
|
onClick={() => setSelectedConversation(item.id)}
|
||||||
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
|
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
|
||||||
>
|
>
|
||||||
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}>
|
<Card style={getCardStyle()} variant={true} size="small" extra={cardExtra} title={cardTitle}>
|
||||||
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
|
<div
|
||||||
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "70%",
|
||||||
|
textAlign: "left"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cardContentLeft}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "30%",
|
||||||
|
textAlign: "right"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cardContentRight}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
);
|
);
|
||||||
@@ -85,7 +134,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
|
|||||||
<div className="chat-list-container">
|
<div className="chat-list-container">
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
data={sortedConversationList}
|
data={sortedConversationList}
|
||||||
itemContent={(index) => renderConversation(index)}
|
itemContent={(index) => renderConversation(index, t)}
|
||||||
style={{ height: "100%", width: "100%" }}
|
style={{ height: "100%", width: "100%" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
|||||||
import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component";
|
import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component";
|
||||||
import ChatPresetsComponent from "../chat-presets/chat-presets.component";
|
import ChatPresetsComponent from "../chat-presets/chat-presets.component";
|
||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
import { GET_PHONE_NUMBER_CONSENT } from "../../graphql/consent.queries";
|
|
||||||
import { phone } from "phone";
|
import { phone } from "phone";
|
||||||
|
import { GET_PHONE_NUMBER_OPT_OUT } from "../../graphql/phone-number-opt-out.queries";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
@@ -31,12 +31,12 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
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: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUT, {
|
||||||
variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone },
|
variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone },
|
||||||
fetchPolicy: "cache-and-network"
|
fetchPolicy: "cache-and-network"
|
||||||
});
|
});
|
||||||
|
|
||||||
const isConsented = consentData?.phone_number_consent?.[0]?.consent_status ?? false;
|
const isOptedOut = !!optOutData?.phone_number_opt_out?.[0];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputArea.current.focus();
|
inputArea.current.focus();
|
||||||
@@ -45,7 +45,7 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
|
|||||||
const handleEnter = () => {
|
const handleEnter = () => {
|
||||||
const selectedImages = selectedMedia.filter((i) => i.isSelected);
|
const selectedImages = selectedMedia.filter((i) => i.isSelected);
|
||||||
if ((message === "" || !message) && selectedImages.length === 0) return;
|
if ((message === "" || !message) && selectedImages.length === 0) return;
|
||||||
if (!isConsented) return;
|
if (isOptedOut) return; // Prevent sending if phone number is opted out
|
||||||
logImEXEvent("messaging_send_message");
|
logImEXEvent("messaging_send_message");
|
||||||
|
|
||||||
if (selectedImages.length < 11) {
|
if (selectedImages.length < 11) {
|
||||||
@@ -69,7 +69,7 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="imex-flex-row" style={{ width: "100%" }}>
|
<div className="imex-flex-row" style={{ width: "100%" }}>
|
||||||
{!isConsented && <Alert message={t("messaging.errors.no_consent")} type="warning" style={{ marginBottom: 8 }} />}
|
{isOptedOut && <Alert message={t("messaging.errors.no_consent")} type="warning" style={{ marginBottom: 8 }} />}
|
||||||
<ChatPresetsComponent className="imex-flex-row__margin" />
|
<ChatPresetsComponent className="imex-flex-row__margin" />
|
||||||
<ChatMediaSelector
|
<ChatMediaSelector
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
@@ -84,18 +84,18 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
|
|||||||
ref={inputArea}
|
ref={inputArea}
|
||||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||||
value={message}
|
value={message}
|
||||||
disabled={isSending || !isConsented}
|
disabled={isSending || isOptedOut}
|
||||||
placeholder={t("messaging.labels.typeamessage")}
|
placeholder={t("messaging.labels.typeamessage")}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
onPressEnter={(event) => {
|
onPressEnter={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!event.shiftKey && isConsented) handleEnter();
|
if (!event.shiftKey && !isOptedOut) handleEnter();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<SendOutlined
|
<SendOutlined
|
||||||
className="chat-send-message-button"
|
className="chat-send-message-button"
|
||||||
disabled={!isConsented || message === "" || !message}
|
disabled={isOptedOut || message === "" || !message}
|
||||||
onClick={handleEnter}
|
onClick={handleEnter}
|
||||||
/>
|
/>
|
||||||
<Spin
|
<Spin
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ 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, selectCurrentUser } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
import { GET_PHONE_NUMBER_CONSENTS } from "../../graphql/consent.queries";
|
import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries";
|
||||||
|
|
||||||
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
|
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
|
||||||
import { TimeAgoFormatter } from "../../utils/DateFormatter";
|
import { TimeAgoFormatter } from "../../utils/DateFormatter";
|
||||||
import { phone } from "phone";
|
import { phone } from "phone";
|
||||||
@@ -23,7 +24,7 @@ function PhoneNumberConsentList({ bodyshop, currentUser }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const { loading, data, refetch } = useQuery(GET_PHONE_NUMBER_CONSENTS, {
|
const { loading, data, refetch } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, {
|
||||||
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined },
|
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined },
|
||||||
fetchPolicy: "network-only"
|
fetchPolicy: "network-only"
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { gql } from "@apollo/client";
|
|
||||||
|
|
||||||
export const GET_PHONE_NUMBER_CONSENT = gql`
|
|
||||||
query GET_PHONE_NUMBER_CONSENT($bodyshopid: uuid!, $phone_number: String!) {
|
|
||||||
phone_number_consent(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
|
|
||||||
id
|
|
||||||
bodyshopid
|
|
||||||
phone_number
|
|
||||||
consent_status
|
|
||||||
created_at
|
|
||||||
updated_at
|
|
||||||
consent_updated_at
|
|
||||||
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!, $search: String) {
|
|
||||||
phone_number_consent(
|
|
||||||
where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _ilike: $search } }
|
|
||||||
order_by: [{ phone_number: asc }, { consent_updated_at: desc }]
|
|
||||||
) {
|
|
||||||
id
|
|
||||||
bodyshopid
|
|
||||||
phone_number
|
|
||||||
consent_status
|
|
||||||
created_at
|
|
||||||
updated_at
|
|
||||||
consent_updated_at
|
|
||||||
phone_number_consent_history(order_by: { changed_at: desc }, limit: 1) {
|
|
||||||
id
|
|
||||||
reason
|
|
||||||
changed_at
|
|
||||||
old_value
|
|
||||||
new_value
|
|
||||||
changed_by
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
28
client/src/graphql/phone-number-opt-out.queries.js
Normal file
28
client/src/graphql/phone-number-opt-out.queries.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { gql } from "@apollo/client";
|
||||||
|
|
||||||
|
export const GET_PHONE_NUMBER_OPT_OUT = gql`
|
||||||
|
query GET_PHONE_NUMBER_OPT_OUT($bodyshopid: uuid!, $phone_number: String!) {
|
||||||
|
phone_number_consent(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
|
||||||
|
id
|
||||||
|
bodyshopid
|
||||||
|
phone_number
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_PHONE_NUMBER_OPT_OUTS = gql`
|
||||||
|
query GET_PHONE_NUMBER_OPT_OUTS($bodyshopid: uuid!, $search: String) {
|
||||||
|
phone_number_consent(
|
||||||
|
where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _ilike: $search } }
|
||||||
|
order_by: [{ phone_number: asc }, { consent_updated_at: desc }]
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
bodyshopid
|
||||||
|
phone_number
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -656,7 +656,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
"consent_settings": "Consent Settings",
|
"consent_settings": "Phone Number Opt-Out List",
|
||||||
"2tiername": "Name => RO",
|
"2tiername": "Name => RO",
|
||||||
"2tiersetup": "2 Tier Setup",
|
"2tiersetup": "2 Tier Setup",
|
||||||
"2tiersource": "Source => RO",
|
"2tiersource": "Source => RO",
|
||||||
@@ -3872,7 +3872,7 @@
|
|||||||
"updated_at": "Last Updated"
|
"updated_at": "Last Updated"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Phone Number Opt-Out list"
|
"title": "Phone Number Opt-Out List"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ const { INSERT_MESSAGE } = require("../graphql-client/queries");
|
|||||||
const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY);
|
const 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 } = req.body;
|
||||||
const {
|
const {
|
||||||
ioRedis,
|
ioRedis,
|
||||||
logger,
|
logger,
|
||||||
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom },
|
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, {
|
||||||
|
|||||||
Reference in New Issue
Block a user