diff --git a/client/package-lock.json b/client/package-lock.json index 2939dbd7c..12ba5dfb3 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -21,8 +21,8 @@ "@jsreport/browser-client": "^3.1.0", "@reduxjs/toolkit": "^2.8.2", "@sentry/cli": "^2.45.0", - "@sentry/react": "^9.21.0", - "@sentry/vite-plugin": "^3.4.0", + "@sentry/react": "^9.22.0", + "@sentry/vite-plugin": "^3.5.0", "@splitsoftware/splitio-react": "^2.1.1", "@tanem/react-nprogress": "^5.0.53", "antd": "^5.25.2", @@ -95,7 +95,7 @@ "@emotion/react": "^11.14.0", "@eslint/js": "^9.27.0", "@playwright/test": "^1.51.1", - "@sentry/webpack-plugin": "^3.4.0", + "@sentry/webpack-plugin": "^3.5.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -4461,88 +4461,88 @@ "license": "MIT" }, "node_modules/@sentry-internal/browser-utils": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.21.0.tgz", - "integrity": "sha512-/lJ5EVUDbsVsPH/sSXwWBERVtzi4kWYeFLc+u+1zr4NrfDrGnPJ5mVS1VlHwtBmYIIWv8harLP+CReg3nDcXdw==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.22.0.tgz", + "integrity": "sha512-Ou1tBnVxFAIn8i9gvrWzRotNJQYiu3awNXpsFCw6qFwmiKAVPa6b13vCdolhXnrIiuR77jY1LQnKh9hXpoRzsg==", "license": "MIT", "dependencies": { - "@sentry/core": "9.21.0" + "@sentry/core": "9.22.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/feedback": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.21.0.tgz", - "integrity": "sha512-Z234NgcWolFpmztCh+9smC6WlO8By5t4KucHNfYSQ0xQYQCxPL5iChj3JpF4dwv+qCYXhDFLQFQbK0U3Px056g==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.22.0.tgz", + "integrity": "sha512-zgMVkoC61fgi41zLcSZA59vOtKxcLrKBo1ECYhPD1hxEaneNqY5fhXDwlQBw96P5l2yqkgfX6YZtSdU4ejI9yA==", "license": "MIT", "dependencies": { - "@sentry/core": "9.21.0" + "@sentry/core": "9.22.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.21.0.tgz", - "integrity": "sha512-7mq3Bsp8EJa3YTIYgmWfNgJdvbeaAJ6VYsqi0yxR/vNGxY3qH+PLlv+ZOEXI2U0CL6vhqFPbqmxiUOCuAjnpGg==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.22.0.tgz", + "integrity": "sha512-9GOycoKbrclcRXfcbNV8svbmAsOS5R4wXBQmKF4pFLkmFA/lJv9kdZSNYkRvkrxdNfbMIJXP+DV9EqTZcryXig==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "9.21.0", - "@sentry/core": "9.21.0" + "@sentry-internal/browser-utils": "9.22.0", + "@sentry/core": "9.22.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.21.0.tgz", - "integrity": "sha512-4tHiNil8qXphaql2YXLGA/wlm0hxaadrh7x8/KErn1iy3vJpn7t/Kka5uug7c2UWhtveS6dgGmqjSkDxM5h9bA==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.22.0.tgz", + "integrity": "sha512-EcG9IMSEalFe49kowBTJObWjof/iHteDwpyuAszsFDdQUYATrVUtwpwN7o52vDYWJud4arhjrQnMamIGxa79eQ==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "9.21.0", - "@sentry/core": "9.21.0" + "@sentry-internal/replay": "9.22.0", + "@sentry/core": "9.22.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/babel-plugin-component-annotate": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.4.0.tgz", - "integrity": "sha512-tSzfc3aE7m0PM0Aj7HBDet5llH9AB9oc+tBQ8AvOqUSnWodLrNCuWeQszJ7mIBovD3figgCU3h0cvI6U5cDtsg==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.5.0.tgz", + "integrity": "sha512-s2go8w03CDHbF9luFGtBHKJp4cSpsQzNVqgIa9Pfa4wnjipvrK6CxVT4icpLA3YO6kg5u622Yoa5GF3cJdippw==", "license": "MIT", "engines": { "node": ">= 14" } }, "node_modules/@sentry/browser": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.21.0.tgz", - "integrity": "sha512-NF0G104JRP2TZ2hpMHElO4bEEUdBWknKSh2d0SRyGpJFVfOQG3oRHczXWH08A5InA/lNrS9LEdodUhiFue+F3A==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.22.0.tgz", + "integrity": "sha512-3TeRm74dvX0JdjX0AgkQa+22iUHwHnY+Q6M05NZ+tDeCNHGK/mEBTeqquS1oQX67jWyuvYmG3VV6RJUxtG9Paw==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "9.21.0", - "@sentry-internal/feedback": "9.21.0", - "@sentry-internal/replay": "9.21.0", - "@sentry-internal/replay-canvas": "9.21.0", - "@sentry/core": "9.21.0" + "@sentry-internal/browser-utils": "9.22.0", + "@sentry-internal/feedback": "9.22.0", + "@sentry-internal/replay": "9.22.0", + "@sentry-internal/replay-canvas": "9.22.0", + "@sentry/core": "9.22.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/bundler-plugin-core": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.4.0.tgz", - "integrity": "sha512-X1Q41AsQ6xcT6hB4wYmBDBukndKM/inT4IsR7pdKLi7ICpX2Qq6lisamBAEPCgEvnLpazSFguaiC0uiwMKAdqw==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.5.0.tgz", + "integrity": "sha512-zDzPrhJqAAy2VzV4g540qAZH4qxzisstK2+NIJPZUUKztWRWUV2cMHsyUtdctYgloGkLyGpZJBE3RE6dmP/xqQ==", "license": "MIT", "dependencies": { "@babel/core": "^7.18.5", - "@sentry/babel-plugin-component-annotate": "3.4.0", + "@sentry/babel-plugin-component-annotate": "3.5.0", "@sentry/cli": "2.42.2", "dotenv": "^16.3.1", "find-up": "^5.0.0", @@ -4902,22 +4902,22 @@ } }, "node_modules/@sentry/core": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.21.0.tgz", - "integrity": "sha512-K0a72Evg0fzc52Oe8R8Op5TyUMzORkk4ytt3G24lSnF4hh8NPf0m6VGkEUgQRPj27g2bF6tq9fCNsJILsf1PDA==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.22.0.tgz", + "integrity": "sha512-ixvtKmPF42Y6ckGUbFlB54OWI75H2gO5UYHojO6eXFpS7xO3ZGgV/QH6wb40mWK+0w5XZ0233FuU9VpsuE6mKA==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@sentry/react": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.21.0.tgz", - "integrity": "sha512-RGbyVo4fS7SX2AjEpdRXDo4C4IYIx0zQcI5bSTgySuhxL0JAxohcuSsNWpx48QkJwK/avtmlmCIPKgbvhF16TQ==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.22.0.tgz", + "integrity": "sha512-mI43NnioBYdG5TiXqRlhV1feZs9bnrrl+k5HOHBK7VQtymaXO0fkcsRLZTkdSgLRLMJGasZuvVhq2xK+18QyWQ==", "license": "MIT", "dependencies": { - "@sentry/browser": "9.21.0", - "@sentry/core": "9.21.0", + "@sentry/browser": "9.22.0", + "@sentry/core": "9.22.0", "hoist-non-react-statics": "^3.3.2" }, "engines": { @@ -4928,12 +4928,12 @@ } }, "node_modules/@sentry/vite-plugin": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-3.4.0.tgz", - "integrity": "sha512-pUFBGrKsHuc8K6A7B1wU2nx65n9aIzvTlcHX9yZ1qvjEO0cZFih0JCwu1Fcav/yrtT9RMN44L/ugu/kMBHQhjQ==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-3.5.0.tgz", + "integrity": "sha512-jUnpTdpicG8wefamw7eNo2uO+Q3KCbOAiF76xH4gfNHSW6TN2hBfOtmLu7J+ive4c0Al3+NEHz19bIPR0lkwWg==", "license": "MIT", "dependencies": { - "@sentry/bundler-plugin-core": "3.4.0", + "@sentry/bundler-plugin-core": "3.5.0", "unplugin": "1.0.1" }, "engines": { @@ -4941,13 +4941,13 @@ } }, "node_modules/@sentry/webpack-plugin": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-3.4.0.tgz", - "integrity": "sha512-i+nAxxniJV5ovijojjTF5n+Yj08Xk8my+vm8+oo0C0I7xcnI2gOKft6B0sJOq01CNbo85X5m/3/edL0PKoWE9w==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-3.5.0.tgz", + "integrity": "sha512-xvclj0QY2HyU7uJLzOlHSrZQBDwfnGKJxp8mmlU4L7CwmK+8xMCqlO7tYZoqE4K/wU3c2xpXql70x8qmvNMxzQ==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/bundler-plugin-core": "3.4.0", + "@sentry/bundler-plugin-core": "3.5.0", "unplugin": "1.0.1", "uuid": "^9.0.0" }, diff --git a/client/package.json b/client/package.json index c6bc4f515..40c52b409 100644 --- a/client/package.json +++ b/client/package.json @@ -20,8 +20,8 @@ "@jsreport/browser-client": "^3.1.0", "@reduxjs/toolkit": "^2.8.2", "@sentry/cli": "^2.45.0", - "@sentry/react": "^9.21.0", - "@sentry/vite-plugin": "^3.4.0", + "@sentry/react": "^9.22.0", + "@sentry/vite-plugin": "^3.5.0", "@splitsoftware/splitio-react": "^2.1.1", "@tanem/react-nprogress": "^5.0.53", "antd": "^5.25.2", @@ -135,7 +135,7 @@ "@emotion/react": "^11.14.0", "@eslint/js": "^9.27.0", "@playwright/test": "^1.51.1", - "@sentry/webpack-plugin": "^3.4.0", + "@sentry/webpack-plugin": "^3.5.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", diff --git a/client/src/components/chat-affix/chat-affix.container.jsx b/client/src/components/chat-affix/chat-affix.container.jsx index 9885b8551..b370b221c 100644 --- a/client/src/components/chat-affix/chat-affix.container.jsx +++ b/client/src/components/chat-affix/chat-affix.container.jsx @@ -34,16 +34,14 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) { SubscribeToTopicForFCMNotification(); - //Register WS handlers + // Register WebSocket handlers if (socket && socket.connected) { registerMessagingHandlers({ socket, client }); - } - return () => { - if (socket && socket.connected) { + return () => { unregisterMessagingHandlers({ socket }); - } - }; + }; + } }, [bodyshop, socket, t, client]); if (!bodyshop || !bodyshop.messagingservicesid) return <>; diff --git a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx index 16d4c0bf1..70bfc5b5d 100644 --- a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx +++ b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx @@ -1,5 +1,5 @@ import { Badge, Card, List, Space, Tag } from "antd"; -import React, { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { connect } from "react-redux"; import { Virtuoso } from "react-virtuoso"; import { createStructuredSelector } from "reselect"; @@ -10,35 +10,63 @@ import PhoneFormatter from "../../utils/PhoneFormatter"; import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component"; import _ from "lodash"; import "./chat-conversation-list.styles.scss"; +import { useQuery } from "@apollo/client"; +import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries.js"; +import { phone } from "phone"; +import { useTranslation } from "react-i18next"; +import { selectBodyshop } from "../../redux/user/user.selectors"; const mapStateToProps = createStructuredSelector({ - selectedConversation: selectSelectedConversation + selectedConversation: selectSelectedConversation, + bodyshop: selectBodyshop }); const mapDispatchToProps = (dispatch) => ({ setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId)) }); -function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) { - // That comma is there for a reason, do not remove it +function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) { + const { t } = useTranslation(); const [, forceUpdate] = useState(false); - // Re-render every minute + const phoneNumbers = conversationList.map((item) => phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, "")); + + const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, { + variables: { + bodyshopid: bodyshop.id, + phone_numbers: phoneNumbers + }, + skip: !conversationList.length, + fetchPolicy: "cache-and-network" + }); + + const optOutMap = useMemo(() => { + const map = new Map(); + optOutData?.phone_number_opt_out?.forEach((optOut) => { + map.set(optOut.phone_number, true); + }); + return map; + }, [optOutData?.phone_number_opt_out]); + useEffect(() => { const interval = setInterval(() => { - forceUpdate((prev) => !prev); // Toggle state to trigger re-render - }, 60000); // 1 minute in milliseconds - - return () => clearInterval(interval); // Cleanup on unmount + forceUpdate((prev) => !prev); + }, 60000); + return () => clearInterval(interval); }, []); - // Memoize the sorted conversation list - const sortedConversationList = React.useMemo(() => { + const sortedConversationList = useMemo(() => { return _.orderBy(conversationList, ["updated_at"], ["desc"]); }, [conversationList]); - const renderConversation = (index) => { + const renderConversation = (index, t) => { const item = sortedConversationList[index]; + const normalizedPhone = phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""); + // Check if the phone number exists in the consentMap + const hasOptOutEntry = optOutMap.has(normalizedPhone); + // Only consider it non-consented if it exists and consent_status is false + const isOptedOut = hasOptOutEntry ? optOutMap.get(normalizedPhone) : true; + const cardContentRight = {item.updated_at}; const cardContentLeft = item.job_conversations.length > 0 @@ -60,7 +88,12 @@ function ChatConversationListComponent({ conversationList, selectedConversation, ); - const cardExtra = ; + const cardExtra = ( + <> + + {hasOptOutEntry && !isOptedOut && {t("messaging.labels.no_consent")}} + + ); const getCardStyle = () => item.id === selectedConversation @@ -73,9 +106,25 @@ function ChatConversationListComponent({ conversationList, selectedConversation, onClick={() => setSelectedConversation(item.id)} className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`} > - -
{cardContentLeft}
-
{cardContentRight}
+ +
+ {cardContentLeft} +
+
+ {cardContentRight} +
); @@ -85,7 +134,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
renderConversation(index)} + itemContent={(index) => renderConversation(index, t)} style={{ height: "100%", width: "100%" }} />
diff --git a/client/src/components/chat-conversation-list/chat-conversation-list.styles.scss b/client/src/components/chat-conversation-list/chat-conversation-list.styles.scss index e6169777c..86cf06152 100644 --- a/client/src/components/chat-conversation-list/chat-conversation-list.styles.scss +++ b/client/src/components/chat-conversation-list/chat-conversation-list.styles.scss @@ -24,7 +24,7 @@ /* Add spacing and better alignment for items */ .chat-list-item { - padding: 0.5rem 0; /* Add spacing between list items */ + padding: 0.2rem 0; /* Add spacing between list items */ .ant-card { border-radius: 8px; /* Slight rounding for card edges */ diff --git a/client/src/components/chat-media-selector/chat-media-selector.component.jsx b/client/src/components/chat-media-selector/chat-media-selector.component.jsx index b7e7d64a3..162789fb6 100644 --- a/client/src/components/chat-media-selector/chat-media-selector.component.jsx +++ b/client/src/components/chat-media-selector/chat-media-selector.component.jsx @@ -37,7 +37,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c fetchPolicy: "network-only", nextFetchPolicy: "network-only", variables: { - jobId: conversation.job_conversations[0] && conversation.job_conversations[0].jobid + jobId: conversation.job_conversations[0] && conversation.job_conversations[0]?.jobid }, skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0 @@ -67,14 +67,14 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c <> {!bodyshop.uselocalmediaserver && ( )} {bodyshop.uselocalmediaserver && open && ( )} @@ -89,7 +89,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c {bodyshop.uselocalmediaserver && open && ( )} diff --git a/client/src/components/chat-send-message/chat-send-message.component.jsx b/client/src/components/chat-send-message/chat-send-message.component.jsx index 824d5e591..798532a3e 100644 --- a/client/src/components/chat-send-message/chat-send-message.component.jsx +++ b/client/src/components/chat-send-message/chat-send-message.component.jsx @@ -1,5 +1,5 @@ import { LoadingOutlined, SendOutlined } from "@ant-design/icons"; -import { Input, Spin } from "antd"; +import { Alert, Input, Spin } from "antd"; import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; @@ -10,6 +10,9 @@ 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 { phone } from "phone"; +import { GET_PHONE_NUMBER_OPT_OUT } from "../../graphql/phone-number-opt-out.queries"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -25,16 +28,24 @@ 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: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUT, { + variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone }, + fetchPolicy: "cache-and-network" + }); + + const isOptedOut = !!optOutData?.phone_number_opt_out?.[0]; 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 (isOptedOut) return; // Prevent sending if phone number is opted out 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,7 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi return (
+ {isOptedOut && } setMessage(e.target.value)} onPressEnter={(event) => { event.preventDefault(); - if (!!!event.shiftKey) handleEnter(); + if (!event.shiftKey && !isOptedOut) handleEnter(); }} /> ({}); + +function PhoneNumberConsentList({ bodyshop, currentUser }) { + const { t } = useTranslation(); + const [search, setSearch] = useState(""); + const notification = useNotification(); + const { loading, data, refetch } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, { + variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined }, + fetchPolicy: "network-only" + }); + const client = useApolloClient(); + const { socket } = useSocket(); + + const columns = [ + { + title: t("consent.phone_number"), + dataIndex: "phone_number", + render: (text) => {text}, + sorter: (a, b) => a.phone_number.localeCompare(b.phone_number) + }, + { + title: t("consent.updated_at"), + dataIndex: "consent_updated_at", + render: (text) => {text} + } + ]; + + return ( +
+ setSearch(value)} + style={{ marginBottom: 16 }} + /> + + + + ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(PhoneNumberConsentList); diff --git a/client/src/components/shop-info/shop-info.consent.component.jsx b/client/src/components/shop-info/shop-info.consent.component.jsx new file mode 100644 index 000000000..15754c4e6 --- /dev/null +++ b/client/src/components/shop-info/shop-info.consent.component.jsx @@ -0,0 +1,25 @@ +import { 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 PhoneNumberConsentList from "../phone-number-consent/phone-number-consent.component"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop +}); + +const mapDispatchToProps = (dispatch) => ({}); + +function ShopInfoConsentComponent({ bodyshop }) { + const { t } = useTranslation(); + + return ( +
+ {t("settings.title")} + {} +
+ ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoConsentComponent); diff --git a/client/src/graphql/phone-number-opt-out.queries.js b/client/src/graphql/phone-number-opt-out.queries.js new file mode 100644 index 000000000..d77e0aa25 --- /dev/null +++ b/client/src/graphql/phone-number-opt-out.queries.js @@ -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 + } + } +`; diff --git a/client/src/pages/shop/shop.page.component.jsx b/client/src/pages/shop/shop.page.component.jsx index b4b354b1d..b6ded16f6 100644 --- a/client/src/pages/shop/shop.page.component.jsx +++ b/client/src/pages/shop/shop.page.component.jsx @@ -10,10 +10,10 @@ import ShopCsiConfig from "../../components/shop-csi-config/shop-csi-config.comp import ShopEmployeesContainer from "../../components/shop-employees/shop-employees.container"; import ShopInfoContainer from "../../components/shop-info/shop-info.container"; import ShopInfoUsersComponent from "../../components/shop-users/shop-users.component"; +import ShopInfoConsentComponent from "../../components/shop-info/shop-info.consent.component"; import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; import { selectBodyshop } from "../../redux/user/user.selectors"; import InstanceRenderManager from "../../utils/instanceRenderMgr"; - import { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component"; import ShopTeamsContainer from "../../components/shop-teams/shop-teams.container"; @@ -91,6 +91,14 @@ export function ShopPage({ bodyshop, setSelectedHeader, setBreadcrumbs }) { children: }); } + + // Add Consent Settings tab + items.push({ + key: "consent", + label: t("bodyshop.labels.consent_settings"), + children: + }); + return ( history({ search: `?tab=${key}` })} items={items} /> diff --git a/client/src/redux/user/user.reducer.js b/client/src/redux/user/user.reducer.js index 0042115ff..eebb32433 100644 --- a/client/src/redux/user/user.reducer.js +++ b/client/src/redux/user/user.reducer.js @@ -105,7 +105,6 @@ const userReducer = (state = INITIAL_STATE, action) => { ...action.payload //Spread current user details in. } }; - case UserActionTypes.SET_SHOP_DETAILS: return { ...state, @@ -126,6 +125,7 @@ const userReducer = (state = INITIAL_STATE, action) => { ...state, imexshopid: action.payload }; + default: return state; } diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index e753871c4..67ddcb72d 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -656,6 +656,7 @@ } }, "labels": { + "consent_settings": "Phone Number Opt-Out List", "2tiername": "Name => RO", "2tiersetup": "2 Tier Setup", "2tiersource": "Source => RO", @@ -2377,7 +2378,8 @@ "errors": { "invalidphone": "The phone number is invalid. Unable to open conversation. ", "noattachedjobs": "No Jobs have been associated to this conversation. ", - "updatinglabel": "Error updating label. {{error}}" + "updatinglabel": "Error updating label. {{error}}", + "no_consent": "This phone number has not consented to receive messages." }, "labels": { "addlabel": "Add a label to this conversation.", @@ -2393,7 +2395,8 @@ "selectmedia": "Select Media", "sentby": "Sent by {{by}} at {{time}}", "typeamessage": "Send a message...", - "unarchive": "Unarchive" + "unarchive": "Unarchive", + "no_consent": "No Consent" }, "render": { "conversation_list": "Conversation List" @@ -3862,6 +3865,14 @@ "validation": { "unique_vendor_name": "You must enter a unique vendor name." } + }, + "consent": { + "phone_number": "Phone Number", + "status": "Consent Status", + "updated_at": "Last Updated" + }, + "settings": { + "title": "Phone Number Opt-Out List" } } } diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 17c162e07..025166032 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -957,7 +957,6 @@ - enforce_conversion_category - enforce_conversion_csr - enforce_referral - - enforce_sms_consent - entegral_configuration - entegral_id - features @@ -1068,7 +1067,6 @@ - enforce_conversion_category - enforce_conversion_csr - enforce_referral - - enforce_sms_consent - federal_tax_id - id - inhousevendorid @@ -5864,104 +5862,12 @@ url: '{{$base_url}}/opensearch' version: 2 - table: - name: phone_number_consent + name: phone_number_opt_out schema: public object_relationships: - name: bodyshop using: foreign_key_constraint_on: bodyshopid - array_relationships: - - name: phone_number_consent_histories - using: - foreign_key_constraint_on: - column: phone_number_consent_id - table: - name: phone_number_consent_history - schema: public - insert_permissions: - - role: user - permission: - check: - bodyshop: - associations: - _and: - - user: - authid: - _eq: X-Hasura-User-Id - - active: - _eq: true - columns: - - consent_status - - phone_number - - consent_updated_at - - created_at - - updated_at - - bodyshopid - - id - comment: "" - select_permissions: - - role: user - permission: - columns: - - consent_status - - phone_number - - consent_updated_at - - created_at - - updated_at - - bodyshopid - - id - filter: - bodyshop: - associations: - _and: - - user: - authid: - _eq: X-Hasura-User-Id - - active: - _eq: true - comment: "" - update_permissions: - - role: user - permission: - columns: - - bodyshopid - - consent_status - - consent_updated_at - - created_at - - phone_number - - updated_at - filter: {} - check: null - comment: "" -- table: - name: phone_number_consent_history - schema: public - object_relationships: - - name: phone_number_consent - using: - foreign_key_constraint_on: phone_number_consent_id - select_permissions: - - role: user - permission: - columns: - - new_value - - old_value - - changed_by - - reason - - changed_at - - id - - phone_number_consent_id - filter: - phone_number_consent: - bodyshop: - associations: - _and: - - user: - authid: - _eq: X-Hasura-User-Id - - active: - _eq: true - comment: "" - table: name: phonebook schema: public diff --git a/hasura/migrations/1747775597734_alter_table_public_phone_number_consent_history_alter_column_old_value/down.sql b/hasura/migrations/1747775597734_alter_table_public_phone_number_consent_history_alter_column_old_value/down.sql new file mode 100644 index 000000000..fb33566b8 --- /dev/null +++ b/hasura/migrations/1747775597734_alter_table_public_phone_number_consent_history_alter_column_old_value/down.sql @@ -0,0 +1 @@ +alter table "public"."phone_number_consent_history" alter column "old_value" set not null; diff --git a/hasura/migrations/1747775597734_alter_table_public_phone_number_consent_history_alter_column_old_value/up.sql b/hasura/migrations/1747775597734_alter_table_public_phone_number_consent_history_alter_column_old_value/up.sql new file mode 100644 index 000000000..406cf8efc --- /dev/null +++ b/hasura/migrations/1747775597734_alter_table_public_phone_number_consent_history_alter_column_old_value/up.sql @@ -0,0 +1 @@ +alter table "public"."phone_number_consent_history" alter column "old_value" drop not null; diff --git a/hasura/migrations/1747850002182_drop_table_public_phone_number_consent_history/down.sql b/hasura/migrations/1747850002182_drop_table_public_phone_number_consent_history/down.sql new file mode 100644 index 000000000..8b7230c77 --- /dev/null +++ b/hasura/migrations/1747850002182_drop_table_public_phone_number_consent_history/down.sql @@ -0,0 +1,3 @@ +-- Could not auto-generate a down migration. +-- Please write an appropriate down migration for the SQL below: +-- DROP table "public"."phone_number_consent_history"; diff --git a/hasura/migrations/1747850002182_drop_table_public_phone_number_consent_history/up.sql b/hasura/migrations/1747850002182_drop_table_public_phone_number_consent_history/up.sql new file mode 100644 index 000000000..1093c487f --- /dev/null +++ b/hasura/migrations/1747850002182_drop_table_public_phone_number_consent_history/up.sql @@ -0,0 +1 @@ +DROP table "public"."phone_number_consent_history"; diff --git a/hasura/migrations/1747850205206_alter_table_public_phone_number_consent_drop_column_consent_status/down.sql b/hasura/migrations/1747850205206_alter_table_public_phone_number_consent_drop_column_consent_status/down.sql new file mode 100644 index 000000000..c7f137e6f --- /dev/null +++ b/hasura/migrations/1747850205206_alter_table_public_phone_number_consent_drop_column_consent_status/down.sql @@ -0,0 +1,2 @@ +alter table "public"."phone_number_consent" alter column "consent_status" drop not null; +alter table "public"."phone_number_consent" add column "consent_status" bool; diff --git a/hasura/migrations/1747850205206_alter_table_public_phone_number_consent_drop_column_consent_status/up.sql b/hasura/migrations/1747850205206_alter_table_public_phone_number_consent_drop_column_consent_status/up.sql new file mode 100644 index 000000000..8daf3ed0d --- /dev/null +++ b/hasura/migrations/1747850205206_alter_table_public_phone_number_consent_drop_column_consent_status/up.sql @@ -0,0 +1 @@ +alter table "public"."phone_number_consent" drop column "consent_status" cascade; diff --git a/hasura/migrations/1747850221535_alter_table_public_phone_number_consent_drop_column_consent_updated_at/down.sql b/hasura/migrations/1747850221535_alter_table_public_phone_number_consent_drop_column_consent_updated_at/down.sql new file mode 100644 index 000000000..74f22f6fb --- /dev/null +++ b/hasura/migrations/1747850221535_alter_table_public_phone_number_consent_drop_column_consent_updated_at/down.sql @@ -0,0 +1,3 @@ +alter table "public"."phone_number_consent" alter column "consent_updated_at" set default now(); +alter table "public"."phone_number_consent" alter column "consent_updated_at" drop not null; +alter table "public"."phone_number_consent" add column "consent_updated_at" timestamptz; diff --git a/hasura/migrations/1747850221535_alter_table_public_phone_number_consent_drop_column_consent_updated_at/up.sql b/hasura/migrations/1747850221535_alter_table_public_phone_number_consent_drop_column_consent_updated_at/up.sql new file mode 100644 index 000000000..154b0a1e6 --- /dev/null +++ b/hasura/migrations/1747850221535_alter_table_public_phone_number_consent_drop_column_consent_updated_at/up.sql @@ -0,0 +1 @@ +alter table "public"."phone_number_consent" drop column "consent_updated_at" cascade; diff --git a/hasura/migrations/1747850386584_rename_table_public_phone_number_consent/down.sql b/hasura/migrations/1747850386584_rename_table_public_phone_number_consent/down.sql new file mode 100644 index 000000000..ede0fa6ca --- /dev/null +++ b/hasura/migrations/1747850386584_rename_table_public_phone_number_consent/down.sql @@ -0,0 +1 @@ +alter table "public"."phone_number_opt_out" rename to "phone_number_consent"; diff --git a/hasura/migrations/1747850386584_rename_table_public_phone_number_consent/up.sql b/hasura/migrations/1747850386584_rename_table_public_phone_number_consent/up.sql new file mode 100644 index 000000000..592a8c243 --- /dev/null +++ b/hasura/migrations/1747850386584_rename_table_public_phone_number_consent/up.sql @@ -0,0 +1 @@ +alter table "public"."phone_number_consent" rename to "phone_number_opt_out"; diff --git a/hasura/migrations/1747850458206_alter_table_public_bodyshops_drop_column_enforce_sms_consent/down.sql b/hasura/migrations/1747850458206_alter_table_public_bodyshops_drop_column_enforce_sms_consent/down.sql new file mode 100644 index 000000000..f25ada416 --- /dev/null +++ b/hasura/migrations/1747850458206_alter_table_public_bodyshops_drop_column_enforce_sms_consent/down.sql @@ -0,0 +1,2 @@ +alter table "public"."bodyshops" alter column "enforce_sms_consent" drop not null; +alter table "public"."bodyshops" add column "enforce_sms_consent" bool; diff --git a/hasura/migrations/1747850458206_alter_table_public_bodyshops_drop_column_enforce_sms_consent/up.sql b/hasura/migrations/1747850458206_alter_table_public_bodyshops_drop_column_enforce_sms_consent/up.sql new file mode 100644 index 000000000..57b8d6ebd --- /dev/null +++ b/hasura/migrations/1747850458206_alter_table_public_bodyshops_drop_column_enforce_sms_consent/up.sql @@ -0,0 +1 @@ +alter table "public"."bodyshops" drop column "enforce_sms_consent" cascade; diff --git a/package-lock.json b/package-lock.json index 8e0440f9d..0ee5c9545 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "aws4": "^1.13.2", "axios": "^1.8.4", "better-queue": "^3.8.12", - "bullmq": "^5.52.3", + "bullmq": "^5.53.0", "chart.js": "^4.4.8", "cloudinary": "^2.6.1", "compression": "^1.8.0", @@ -4634,9 +4634,9 @@ } }, "node_modules/bullmq": { - "version": "5.52.3", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.52.3.tgz", - "integrity": "sha512-UaVkg+uSgylYWjD6/d8TVm87SjDVZ5jKwDVh/pJACmStn71aIzOIpGazh2JrkGISgT10Q/lG2I40FhPg0KgNCQ==", + "version": "5.53.0", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.53.0.tgz", + "integrity": "sha512-AbzcwR+9GdgrenolOC9kApF+TkUKZpUCMiFbXgRYw9ivWhOfLCqKeajIptM7NdwhY7cpXgv+QpbweUuQZUxkyA==", "license": "MIT", "dependencies": { "cron-parser": "^4.9.0", diff --git a/package.json b/package.json index a2da483f7..5eec68f9b 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "aws4": "^1.13.2", "axios": "^1.8.4", "better-queue": "^3.8.12", - "bullmq": "^5.52.3", + "bullmq": "^5.53.0", "chart.js": "^4.4.8", "cloudinary": "^2.6.1", "compression": "^1.8.0", diff --git a/server/routes/smsRoutes.js b/server/routes/smsRoutes.js index 1b169747d..c09cc1632 100644 --- a/server/routes/smsRoutes.js +++ b/server/routes/smsRoutes.js @@ -7,7 +7,7 @@ const { status, markConversationRead } = require("../sms/status"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); // Twilio Webhook Middleware for production -// TODO: Look into this because it technically is never validating anything +// TODO: This is never actually doing anything, we should probably verify const twilioWebhookMiddleware = twilio.webhook({ validate: process.env.NODE_ENV === "PRODUCTION" }); router.post("/receive", twilioWebhookMiddleware, receive); diff --git a/server/sms/receive.js b/server/sms/receive.js index f880105e9..bf5262b25 100644 --- a/server/sms/receive.js +++ b/server/sms/receive.js @@ -7,56 +7,17 @@ const { } = require("../graphql-client/queries"); const { phone } = require("phone"); const { admin } = require("../firebase/firebase-handler"); -const logger = require("../utils/logger"); const InstanceManager = require("../utils/instanceMgr").default; /** - * Generate an array of media URLs from the request body - * @param body - * @returns {null|*[]} - */ -const generateMediaArray = (body) => { - const { NumMedia } = body; - if (parseInt(NumMedia) > 0) { - const ret = []; - for (let i = 0; i < parseInt(NumMedia); i++) { - ret.push(body[`MediaUrl${i}`]); - } - return ret; - } else { - return null; - } -}; - -/** - * Handle errors during the message receiving process - * @param req - * @param error - * @param res - * @param context - */ -const handleError = (req, error, res, context) => { - logger.log("sms-inbound-error", "ERROR", "api", null, { - msid: req.body.SmsMessageSid, - text: req.body.Body, - image: !!req.body.MediaUrl0, - image_path: generateMediaArray(req.body), - messagingServiceSid: req.body.MessagingServiceSid, - context, - error - }); - - res.status(500).json({ error: error.message || "Internal Server Error" }); -}; - -/** - * Receive an inbound SMS message + * Receive SMS messages from Twilio and process them * @param req * @param res * @returns {Promise<*>} */ const receive = async (req, res) => { const { + logger, ioRedis, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } } = req; @@ -65,7 +26,7 @@ const receive = async (req, res) => { msid: req.body.SmsMessageSid, text: req.body.Body, image: !!req.body.MediaUrl0, - image_path: generateMediaArray(req.body) + image_path: generateMediaArray(req.body, logger) }; logger.log("sms-inbound", "DEBUG", "api", null, loggerData); @@ -91,7 +52,7 @@ const receive = async (req, res) => { const bodyshop = response.bodyshops[0]; - // Sort conversations by `updated_at` (or `created_at`) and pick the last one + // Step 4: Process conversation const sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const existingConversation = sortedConversations.length ? sortedConversations[sortedConversations.length - 1] @@ -102,16 +63,13 @@ const receive = async (req, res) => { msid: req.body.SmsMessageSid, text: req.body.Body, image: !!req.body.MediaUrl0, - image_path: generateMediaArray(req.body), + image_path: generateMediaArray(req.body, logger), isoutbound: false, - userid: null // Add additional fields as necessary + userid: null }; if (existingConversation) { - // Use the existing conversation conversationid = existingConversation.id; - - // Unarchive the conversation if necessary if (existingConversation.archived) { await client.request(UNARCHIVE_CONVERSATION, { id: conversationid, @@ -119,7 +77,6 @@ const receive = async (req, res) => { }); } } else { - // Create a new conversation const newConversationResponse = await client.request(CREATE_CONVERSATION, { conversation: { bodyshopid: bodyshop.id, @@ -131,13 +88,12 @@ const receive = async (req, res) => { conversationid = createdConversation.id; } - // Ensure `conversationid` is added to the message newMessage.conversationid = conversationid; - // Step 3: Insert the message into the conversation + // Step 5: Insert the message const insertresp = await client.request(INSERT_MESSAGE, { msg: newMessage, - conversationid: conversationid + conversationid }); const message = insertresp?.insert_messages?.returning?.[0]; @@ -147,8 +103,7 @@ const receive = async (req, res) => { throw new Error("Conversation data is missing from the response."); } - // Step 4: Notify clients through Redis - const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id); + // Step 6: Notify clients const conversationRoom = getBodyshopConversationRoom({ bodyshopId: conversation.bodyshop.id, conversationId: conversation.id @@ -161,6 +116,8 @@ const receive = async (req, res) => { msid: message.sid }; + const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id); + ioRedis.to(broadcastRoom).emit("new-message-summary", { ...commonPayload, existingConversation: !!existingConversation, @@ -176,7 +133,7 @@ const receive = async (req, res) => { summary: false }); - // Step 5: Send FCM notification + // Step 7: Send FCM notification const fcmresp = await admin.messaging().send({ topic: `${message.conversation.bodyshop.imexshopid}-messaging`, notification: { @@ -202,10 +159,51 @@ const receive = async (req, res) => { res.status(200).send(""); } catch (e) { - handleError(req, e, res, "RECEIVE_MESSAGE"); + handleError(req, e, res, "RECEIVE_MESSAGE", logger); } }; +/** + * Generate media array from the request body + * @param body + * @param logger + * @returns {null|*[]} + */ +const generateMediaArray = (body, logger) => { + const { NumMedia } = body; + if (parseInt(NumMedia) > 0) { + const ret = []; + for (let i = 0; i < parseInt(NumMedia); i++) { + ret.push(body[`MediaUrl${i}`]); + } + return ret; + } else { + return null; + } +}; + +/** + * Handle error logging and response + * @param req + * @param error + * @param res + * @param context + * @param logger + */ +const handleError = (req, error, res, context, logger) => { + logger.log("sms-inbound-error", "ERROR", "api", null, { + msid: req.body.SmsMessageSid, + text: req.body.Body, + image: !!req.body.MediaUrl0, + image_path: generateMediaArray(req.body, logger), + messagingServiceSid: req.body.MessagingServiceSid, + context, + error + }); + + res.status(500).json({ error: error.message || "Internal Server Error" }); +}; + module.exports = { receive }; diff --git a/server/sms/send.js b/server/sms/send.js index bc0a95da9..fdd2c81ae 100644 --- a/server/sms/send.js +++ b/server/sms/send.js @@ -1,7 +1,6 @@ const twilio = require("twilio"); const { phone } = require("phone"); const { INSERT_MESSAGE } = require("../graphql-client/queries"); -const logger = require("../utils/logger"); const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY); const gqlClient = require("../graphql-client/graphql-client").client; @@ -15,6 +14,7 @@ const send = async (req, res) => { const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body; const { ioRedis, + logger, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } } = req; @@ -26,8 +26,8 @@ const send = async (req, res) => { conversationid, isoutbound: true, userid: req.user.email, - image: req.body.selectedMedia.length > 0, - image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] + image: selectedMedia.length > 0, + image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] }); if (!to || !messagingServiceSid || (!body && selectedMedia.length === 0) || !conversationid) { @@ -39,8 +39,8 @@ const send = async (req, res) => { conversationid, isoutbound: true, userid: req.user.email, - image: req.body.selectedMedia.length > 0, - image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] + image: selectedMedia.length > 0, + image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] }); res.status(400).json({ success: false, message: "Missing required parameter(s)." }); return; @@ -60,8 +60,8 @@ const send = async (req, res) => { conversationid, isoutbound: true, userid: req.user.email, - image: req.body.selectedMedia.length > 0, - image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] + image: selectedMedia.length > 0, + image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] }; try {