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

This commit is contained in:
Dave Richer
2025-05-20 16:04:36 -04:00
parent 9d81c68a4d
commit 83860152a9
12 changed files with 540 additions and 43 deletions

View File

@@ -8,6 +8,7 @@ import ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss";
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { GET_PHONE_NUMBER_CONSENT } from "../../graphql/consent.queries.js";
export function ChatAffixContainer({ bodyshop, chatVisible }) {
const { t } = useTranslation();
@@ -34,16 +35,59 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) {
SubscribeToTopicForFCMNotification();
//Register WS handlers
// Register WebSocket handlers
if (socket && socket.connected) {
registerMessagingHandlers({ socket, client });
}
return () => {
if (socket && socket.connected) {
// Handle consent-changed events
const handleConsentChanged = ({ bodyshopId, phone_number, consent_status }) => {
try {
client.cache.writeQuery(
{
query: GET_PHONE_NUMBER_CONSENT,
variables: { bodyshopid: bodyshopId, phone_number }
},
(data) => {
if (!data?.phone_number_consent?.[0]) {
return {
phone_number_consent: [
{
__typename: "phone_number_consent",
id: null,
bodyshopid: bodyshopId,
phone_number,
consent_status,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
consent_updated_at: new Date().toISOString(),
history: []
}
]
};
}
return {
phone_number_consent: [
{
...data.phone_number_consent[0],
consent_status,
consent_updated_at: new Date().toISOString()
}
]
};
}
);
} catch (error) {
console.error("Error updating consent cache:", error);
}
};
socket.on("consent-changed", handleConsentChanged);
return () => {
socket.off("consent-changed", handleConsentChanged);
unregisterMessagingHandlers({ socket });
}
};
};
}
}, [bodyshop, socket, t, client]);
if (!bodyshop || !bodyshop.messagingservicesid) return <></>;

View File

@@ -10,6 +10,10 @@ import PhoneFormatter from "../../utils/PhoneFormatter";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import _ from "lodash";
import "./chat-conversation-list.styles.scss";
import { useQuery } from "@apollo/client";
import { GET_PHONE_NUMBER_CONSENTS } from "../../graphql/consent.queries.js";
import { phone } from "phone";
import { useTranslation } from "react-i18next";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation
@@ -20,25 +24,45 @@ const mapDispatchToProps = (dispatch) => ({
});
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) {
// That comma is there for a reason, do not remove it
const { t } = useTranslation();
const [, forceUpdate] = useState(false);
// Re-render every minute
// Normalize phone numbers and fetch consent statuses
const phoneNumbers = conversationList.map((item) => phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""));
const { data: consentData, loading: consentLoading } = useQuery(GET_PHONE_NUMBER_CONSENTS, {
variables: {
bodyshopid: conversationList[0]?.bodyshopid,
phone_numbers: phoneNumbers
},
skip: !conversationList.length || !conversationList[0]?.bodyshopid,
fetchPolicy: "cache-and-network"
});
// Create a map of phone number to consent status
const consentMap = React.useMemo(() => {
const map = new Map();
consentData?.phone_number_consent?.forEach((consent) => {
map.set(consent.phone_number, consent.consent_status);
});
return map;
}, [consentData]);
useEffect(() => {
const interval = setInterval(() => {
forceUpdate((prev) => !prev); // Toggle state to trigger re-render
}, 60000); // 1 minute in milliseconds
return () => clearInterval(interval); // Cleanup on unmount
forceUpdate((prev) => !prev);
}, 60000);
return () => clearInterval(interval);
}, []);
// Memoize the sorted conversation list
const sortedConversationList = React.useMemo(() => {
return _.orderBy(conversationList, ["updated_at"], ["desc"]);
}, [conversationList]);
const renderConversation = (index) => {
const renderConversation = (index, t) => {
const item = sortedConversationList[index];
const normalizedPhone = phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
const isConsented = consentMap.get(normalizedPhone) ?? false;
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
const cardContentLeft =
item.job_conversations.length > 0
@@ -60,7 +84,12 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
</>
);
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count} />;
const cardExtra = (
<>
<Badge count={item.messages_aggregate.aggregate.count} />
{!isConsented && <Tag color="red">{t("messaging.labels.no_consent")}</Tag>}
</>
);
const getCardStyle = () =>
item.id === selectedConversation
@@ -73,7 +102,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
onClick={() => setSelectedConversation(item.id)}
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
>
<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 style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
</Card>
@@ -85,7 +114,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
<div className="chat-list-container">
<Virtuoso
data={sortedConversationList}
itemContent={(index) => renderConversation(index)}
itemContent={(index) => renderConversation(index, t)}
style={{ height: "100%", width: "100%" }}
/>
</div>

View File

@@ -24,7 +24,7 @@
/* Add spacing and better alignment for items */
.chat-list-item {
padding: 0.5rem 0; /* Add spacing between list items */
padding: 0.2rem 0; /* Add spacing between list items */
.ant-card {
border-radius: 8px; /* Slight rounding for card edges */

View File

@@ -37,7 +37,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
variables: {
jobId: conversation.job_conversations[0] && conversation.job_conversations[0].jobid
jobId: conversation.job_conversations[0] && conversation.job_conversations[0]?.jobid
},
skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0
@@ -67,14 +67,14 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
<>
{!bodyshop.uselocalmediaserver && (
<JobsDocumentImgproxyGalleryExternal
jobId={conversation.job_conversations[0].jobid}
jobId={conversation.job_conversations[0]?.jobid}
externalMediaState={[selectedMedia, setSelectedMedia]}
/>
)}
{bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0]?.jobid}
/>
)}
</>
@@ -89,7 +89,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
{bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0]?.jobid}
/>
)}
</>

View File

@@ -10,6 +10,10 @@ import { selectIsSending, selectMessage } from "../../redux/messaging/messaging.
import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component";
import ChatPresetsComponent from "../chat-presets/chat-presets.component";
import { useQuery } from "@apollo/client";
import { GET_PHONE_NUMBER_CONSENT } from "../../graphql/consent.queries";
import AlertComponent from "../alert/alert.component";
import { phone } from "phone";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -25,16 +29,23 @@ const mapDispatchToProps = (dispatch) => ({
function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) {
const inputArea = useRef(null);
const [selectedMedia, setSelectedMedia] = useState([]);
const { t } = useTranslation();
const normalizedPhone = phone(conversation.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
const { data: consentData } = useQuery(GET_PHONE_NUMBER_CONSENT, {
variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone },
fetchPolicy: "cache-and-network"
});
const isConsented = consentData?.phone_number_consent?.[0]?.consent_status ?? false;
useEffect(() => {
inputArea.current.focus();
}, [isSending, setMessage]);
const { t } = useTranslation();
const handleEnter = () => {
const selectedImages = selectedMedia.filter((i) => i.isSelected);
if ((message === "" || !message) && selectedImages.length === 0) return;
if (!isConsented) return;
logImEXEvent("messaging_send_message");
if (selectedImages.length < 11) {
@@ -44,7 +55,8 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
messagingServiceSid: bodyshop.messagingservicesid,
conversationid: conversation.id,
selectedMedia: selectedImages,
imexshopid: bodyshop.imexshopid
imexshopid: bodyshop.imexshopid,
bodyshopid: bodyshop.id
};
sendMessage(newMessage);
setSelectedMedia(
@@ -57,6 +69,9 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
return (
<div className="imex-flex-row" style={{ width: "100%" }}>
{!isConsented && (
<AlertComponent message={t("messaging.errors.no_consent")} type="warning" style={{ marginBottom: 8 }} />
)}
<ChatPresetsComponent className="imex-flex-row__margin" />
<ChatMediaSelector
conversation={conversation}
@@ -71,18 +86,18 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
ref={inputArea}
autoSize={{ minRows: 1, maxRows: 4 }}
value={message}
disabled={isSending}
disabled={isSending || !isConsented}
placeholder={t("messaging.labels.typeamessage")}
onChange={(e) => setMessage(e.target.value)}
onPressEnter={(event) => {
event.preventDefault();
if (!!!event.shiftKey) handleEnter();
if (!event.shiftKey && isConsented) handleEnter();
}}
/>
</span>
<SendOutlined
className="chat-send-message-button"
// disabled={message === "" || !message}
disabled={!isConsented || message === "" || !message}
onClick={handleEnter}
/>
<Spin

View File

@@ -0,0 +1,131 @@
import { useMutation, useQuery } from "@apollo/client";
import { Table, Switch, Input, Tooltip, Upload, Button } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import {
GET_PHONE_NUMBER_CONSENTS,
SET_PHONE_NUMBER_CONSENT,
BULK_SET_PHONE_NUMBER_CONSENT
} from "../../graphql/consent.queries.js";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { TimeAgoFormatter } from "../../utils/DateFormatter";
import { UploadOutlined } from "@ant-design/icons";
import { phone } from "phone";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
function PhoneNumberConsentList({ bodyshop }) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const { loading, data } = useQuery(GET_PHONE_NUMBER_CONSENTS, {
variables: { bodyshopid: bodyshop.id, search },
fetchPolicy: "network-only"
});
const [setConsent] = useMutation(SET_PHONE_NUMBER_CONSENT);
const [bulkSetConsent] = useMutation(BULK_SET_PHONE_NUMBER_CONSENT);
const columns = [
{
title: t("consent.phone_number"),
dataIndex: "phone_number",
render: (text) => <PhoneNumberFormatter>{text}</PhoneNumberFormatter>
},
{
title: t("consent.status"),
dataIndex: "consent_status",
render: (status, record) => (
<Tooltip title={record.history?.[0]?.reason || "No audit history"}>
<Switch
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>
)
},
{
title: t("consent.updated_at"),
dataIndex: "consent_updated_at",
render: (text) => <TimeAgoFormatter>{text}</TimeAgoFormatter>
}
];
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 (
<div>
<Input.Search
placeholder={t("general.labels.search")}
onSearch={(value) => setSearch(value)}
style={{ marginBottom: 16 }}
/>
<Upload beforeUpload={handleBulkUpload} accept=".csv" showUploadList={false}>
<Button icon={<UploadOutlined />}>{t("consent.bulk_upload")}</Button>
</Upload>
<Table
columns={columns}
dataSource={data?.phone_number_consent}
loading={loading}
rowKey="id"
style={{ marginTop: 16 }}
/>
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(PhoneNumberConsentList);

View File

@@ -0,0 +1,51 @@
import { useMutation } from "@apollo/client";
import { Switch, Typography } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { UPDATE_BODYSHOP_ENFORCE_CONSENT } from "../../graphql/bodyshop.queries";
import PhoneNumberConsentList from "../phone-number-consent/phone-number-consent.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
function ShopInfoConsentComponent({ bodyshop }) {
const { t } = useTranslation();
const [updateEnforceConsent] = useMutation(UPDATE_BODYSHOP_ENFORCE_CONSENT);
console.dir(bodyshop);
const enforceConsent = bodyshop?.enforce_sms_consent ?? false;
return (
<div>
<Typography.Title level={4}>{t("settings.title")}</Typography.Title>
<div style={{ marginBottom: 16 }}>
<Typography.Text>{t("settings.enforce_sms_consent")}</Typography.Text>
<Switch
checked={enforceConsent}
onChange={(checked) =>
updateEnforceConsent({
variables: { id: bodyshop.id, enforce_sms_consent: checked },
optimisticResponse: {
update_bodyshops_by_pk: {
__typename: "bodyshops",
id: bodyshop.id,
enforce_sms_consent: checked
}
}
})
}
/>
</div>
<PhoneNumberConsentList bodyshop={bodyshop} />
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoConsentComponent);

View File

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

View File

@@ -0,0 +1,90 @@
import { gql } from "@apollo/client";
export const GET_PHONE_NUMBER_CONSENT = gql`
query GET_PHONE_NUMBER_CONSENT($bodyshopid: uuid!, $phone_number: String!) {
phone_number_consent(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
id
bodyshopid
phone_number
consent_status
created_at
updated_at
consent_updated_at
history(order_by: { changed_at: desc }, limit: 1) {
reason
}
}
}
`;
export const GET_PHONE_NUMBER_CONSENTS = gql`
query GET_PHONE_NUMBER_CONSENTS($bodyshopid: uuid!, $phone_numbers: [String!]) {
phone_number_consent(
where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _in: $phone_numbers } }
order_by: { consent_updated_at: desc }
) {
id
bodyshopid
phone_number
consent_status
created_at
updated_at
consent_updated_at
history(order_by: { changed_at: desc }, limit: 1) {
reason
}
}
}
`;
export const SET_PHONE_NUMBER_CONSENT = gql`
mutation SET_PHONE_NUMBER_CONSENT(
$bodyshopid: uuid!
$phone_number: String!
$consent_status: Boolean!
$reason: String!
$changed_by: String!
) {
insert_phone_number_consent_one(
object: {
bodyshopid: $bodyshopid
phone_number: $phone_number
consent_status: $consent_status
consent_updated_at: "now()"
}
on_conflict: {
constraint: phone_number_consent_bodyshopid_phone_number_key
update_columns: [consent_status, consent_updated_at]
}
) {
id
bodyshopid
phone_number
consent_status
created_at
updated_at
consent_updated_at
}
}
`;
export const BULK_SET_PHONE_NUMBER_CONSENT = gql`
mutation BULK_SET_PHONE_NUMBER_CONSENT($objects: [phone_number_consent_insert_input!]!) {
insert_phone_number_consent(
objects: $objects
on_conflict: {
constraint: phone_number_consent_bodyshopid_phone_number_key
update_columns: [consent_status, consent_updated_at]
}
) {
affected_rows
returning {
id
bodyshopid
phone_number
consent_status
consent_updated_at
}
}
}
`;

View File

@@ -10,10 +10,10 @@ import ShopCsiConfig from "../../components/shop-csi-config/shop-csi-config.comp
import ShopEmployeesContainer from "../../components/shop-employees/shop-employees.container";
import ShopInfoContainer from "../../components/shop-info/shop-info.container";
import ShopInfoUsersComponent from "../../components/shop-users/shop-users.component";
import ShopInfoConsentComponent from "../../components/shop-info/shop-info.consent.component";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component";
import ShopTeamsContainer from "../../components/shop-teams/shop-teams.container";
@@ -91,6 +91,14 @@ export function ShopPage({ bodyshop, setSelectedHeader, setBreadcrumbs }) {
children: <ShopCsiConfig />
});
}
// Add Consent Settings tab
items.push({
key: "consent",
label: t("bodyshop.labels.consent_settings"),
children: <ShopInfoConsentComponent bodyshop={bodyshop} />
});
return (
<RbacWrapper action="shop:config">
<Tabs activeKey={search.tab} onChange={(key) => history({ search: `?tab=${key}` })} items={items} />