diff --git a/client/src/components/chat-affix/registerMessagingSocketHandlers.js b/client/src/components/chat-affix/registerMessagingSocketHandlers.js index b50d02c8a..e19c56a93 100644 --- a/client/src/components/chat-affix/registerMessagingSocketHandlers.js +++ b/client/src/components/chat-affix/registerMessagingSocketHandlers.js @@ -142,14 +142,47 @@ export const registerMessagingHandlers = ({ socket, client }) => { }); }; - const handleConversationChanged = (data) => { + const handleConversationChanged = async (data) => { if (!data) return; const { conversationId, type, job_conversations, ...fields } = data; - logLocal("handleConversationChanged", data); - // Identify the conversation in the Apollo cache + const updatedAt = new Date().toISOString(); + + const updateConversationList = (newConversation) => { + try { + const existingList = client.cache.readQuery({ + query: CONVERSATION_LIST_QUERY, + variables: { offset: 0 } + }); + + const updatedList = existingList?.conversations + ? [ + newConversation, + ...existingList.conversations.filter( + (conv) => conv.id !== newConversation.id // Prevent duplicates + ) + ] + : [newConversation]; + + client.cache.writeQuery({ + query: CONVERSATION_LIST_QUERY, + variables: { offset: 0 }, + data: { + conversations: updatedList + } + }); + } catch (error) { + console.error("Error updating conversation list in the cache:", error); + } + }; + + if (type === "conversation-created") { + updateConversationList({ ...fields, job_conversations, updated_at: updatedAt }); + return; + } + const cacheId = client.cache.identify({ __typename: "conversations", id: conversationId @@ -161,20 +194,20 @@ export const registerMessagingHandlers = ({ socket, client }) => { } if (type === "conversation-archived") { - // Remove all messages associated with this conversation - const messageRefs = client.cache.readFragment({ - id: cacheId, - fragment: gql` - fragment ConversationMessages on conversations { - messages { - id + try { + // Evict messages associated with the conversation + const messageRefs = client.cache.readFragment({ + id: cacheId, + fragment: gql` + fragment ConversationMessages on conversations { + messages { + id + } } - } - ` - }); + ` + }); - if (messageRefs?.messages) { - messageRefs.messages.forEach((message) => { + messageRefs?.messages?.forEach((message) => { const messageCacheId = client.cache.identify({ __typename: "messages", id: message.id @@ -183,39 +216,84 @@ export const registerMessagingHandlers = ({ socket, client }) => { client.cache.evict({ id: messageCacheId }); } }); + + // Evict the conversation itself + client.cache.evict({ id: cacheId }); + client.cache.gc(); // Trigger garbage collection + } catch (error) { + console.error("Error archiving conversation:", error); } - - // Evict the conversation itself - client.cache.evict({ id: cacheId }); - client.cache.gc(); // Trigger garbage collection to clean up unused entries - return; } + if (type === "conversation-unarchived") { + try { + // Fetch the conversation from the database if not already in the cache + const existingConversation = client.cache.readQuery({ + query: GET_CONVERSATION_DETAILS, + variables: { conversationId } + }); + + if (!existingConversation) { + const { data: fetchedData } = await client.query({ + query: GET_CONVERSATION_DETAILS, + variables: { conversationId }, + fetchPolicy: "network-only" + }); + + if (fetchedData?.conversations_by_pk) { + const conversationData = fetchedData.conversations_by_pk; + + // Enrich conversation data + const enrichedConversation = { + ...conversationData, + messages_aggregate: { + __typename: "messages_aggregate", + aggregate: { + __typename: "messages_aggregate_fields", + count: conversationData.messages.filter((message) => !message.read && !message.isoutbound).length + } + }, + updated_at: updatedAt + }; + + updateConversationList(enrichedConversation); + } + } + + // Mark the conversation as unarchived in the cache + client.cache.modify({ + id: cacheId, + fields: { + archived: () => false, + updated_at: () => updatedAt + } + }); + } catch (error) { + console.error("Error unarchiving conversation:", error); + } + return; + } + + // Handle other types of updates (e.g., marked read, tags added/removed) client.cache.modify({ id: cacheId, fields: { - // This is a catch-all for just sending it fields off conversation ...Object.fromEntries( - Object.entries(fields).map(([key, value]) => [ - key, - (cached) => (value !== undefined ? value : cached) // Update with new value or keep existing - ]) + Object.entries(fields).map(([key, value]) => [key, (cached) => (value !== undefined ? value : cached)]) ), ...(type === "conversation-marked-read" && { messages_aggregate: () => ({ - aggregate: { count: 0 } // Reset unread count + __typename: "messages_aggregate", + aggregate: { __typename: "messages_aggregate_fields", count: 0 } }) }), ...(type === "tag-added" && { - job_conversations: (existing = []) => { - // Merge existing job_conversations with new ones - return [...existing, ...job_conversations]; - } + job_conversations: (existing = []) => [...existing, ...job_conversations] }), ...(type === "tag-removed" && { job_conversations: (existing = [], { readField }) => - existing.filter((jobConversationRef) => readField("jobid", jobConversationRef) !== data.jobId) + existing.filter((jobRef) => readField("jobid", jobRef) !== data.jobId) }) } }); diff --git a/client/src/components/chat-new-conversation/chat-new-conversation.component.jsx b/client/src/components/chat-new-conversation/chat-new-conversation.component.jsx index b0b6054e4..e45ffb9b6 100644 --- a/client/src/components/chat-new-conversation/chat-new-conversation.component.jsx +++ b/client/src/components/chat-new-conversation/chat-new-conversation.component.jsx @@ -1,11 +1,12 @@ import { PlusCircleFilled } from "@ant-design/icons"; import { Button, Form, Popover } from "antd"; -import React from "react"; +import React, { useContext } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { openChatByPhone } from "../../redux/messaging/messaging.actions"; import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component"; +import SocketContext from "../../contexts/SocketIO/socketContext.jsx"; const mapStateToProps = createStructuredSelector({ //currentUser: selectCurrentUser @@ -17,8 +18,10 @@ const mapDispatchToProps = (dispatch) => ({ export function ChatNewConversation({ openChatByPhone }) { const { t } = useTranslation(); const [form] = Form.useForm(); + const { socket } = useContext(SocketContext); + const handleFinish = (values) => { - openChatByPhone({ phone_num: values.phoneNumber }); + openChatByPhone({ phone_num: values.phoneNumber, socket }); form.resetFields(); }; diff --git a/client/src/components/chat-open-button/chat-open-button.component.jsx b/client/src/components/chat-open-button/chat-open-button.component.jsx index d2f0de528..dcd41c041 100644 --- a/client/src/components/chat-open-button/chat-open-button.component.jsx +++ b/client/src/components/chat-open-button/chat-open-button.component.jsx @@ -1,6 +1,6 @@ import { notification } from "antd"; import parsePhoneNumber from "libphonenumber-js"; -import React from "react"; +import React, { useContext } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { openChatByPhone } from "../../redux/messaging/messaging.actions"; @@ -9,6 +9,7 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter"; import { createStructuredSelector } from "reselect"; import { selectBodyshop } from "../../redux/user/user.selectors"; import { searchingForConversation } from "../../redux/messaging/messaging.selectors"; +import SocketContext from "../../contexts/SocketIO/socketContext.jsx"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -21,6 +22,8 @@ const mapDispatchToProps = (dispatch) => ({ export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobid, openChatByPhone }) { const { t } = useTranslation(); + const { socket } = useContext(SocketContext); + if (!phone) return <>; if (!bodyshop.messagingservicesid) return {phone}; @@ -33,7 +36,7 @@ export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobi const p = parsePhoneNumber(phone, "CA"); if (searchingForConversation) return; //This is to prevent finding the same thing twice. if (p && p.isValid()) { - openChatByPhone({ phone_num: p.formatInternational(), jobid: jobid }); + openChatByPhone({ phone_num: p.formatInternational(), jobid: jobid, socket }); } else { notification["error"]({ message: t("messaging.error.invalidphone") }); } diff --git a/client/src/components/job-at-change/schedule-event.component.jsx b/client/src/components/job-at-change/schedule-event.component.jsx index e7f70ff65..bdf5f1a85 100644 --- a/client/src/components/job-at-change/schedule-event.component.jsx +++ b/client/src/components/job-at-change/schedule-event.component.jsx @@ -3,7 +3,7 @@ import { Button, Divider, Dropdown, Form, Input, notification, Popover, Select, import parsePhoneNumber from "libphonenumber-js"; import dayjs from "../../utils/day"; import queryString from "query-string"; -import React, { useState } from "react"; +import React, { useContext, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { Link, useLocation, useNavigate } from "react-router-dom"; @@ -24,6 +24,7 @@ import ScheduleEventNote from "./schedule-event.note.component"; import { useMutation } from "@apollo/client"; import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries"; import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component"; +import SocketContext from "../../contexts/SocketIO/socketContext.jsx"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -49,6 +50,8 @@ export function ScheduleEventComponent({ const searchParams = queryString.parse(useLocation().search); const [updateAppointment] = useMutation(UPDATE_APPOINTMENT); const [title, setTitle] = useState(event.title); + const { socket } = useContext(SocketContext); + const blockContent = ( ({ setPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "payment" })), - setCardPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "cardPayment" })), - openChatByPhone: (phone) => dispatch(openChatByPhone(phone)), - setMessage: (text) => dispatch(setMessage(text)) + setCardPaymentContext: (context) => + dispatch( + setModalContext({ + context: context, + modal: "cardPayment" + }) + ) }); -export function JobPayments({ - job, - jobRO, - bodyshop, - setMessage, - openChatByPhone, - setPaymentContext, - setCardPaymentContext, - refetch -}) { +export function JobPayments({ job, jobRO, bodyshop, setPaymentContext, setCardPaymentContext, refetch }) { const { treatments: { ImEXPay } } = useSplitTreatments({ @@ -133,7 +127,7 @@ export function JobPayments({ } ]; - //Same as in RO guard. If changed, update in both. + //Same as in RO guard. If changed, update in both. const total = useMemo(() => { return ( job.payments && diff --git a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx index c9c3b8fb7..1fdeeec61 100644 --- a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx +++ b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx @@ -4,7 +4,7 @@ import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { Button, Card, Dropdown, Form, Input, Modal, notification, Popconfirm, Popover, Select, Space } from "antd"; import axios from "axios"; import parsePhoneNumber from "libphonenumber-js"; -import React, { useMemo, useState } from "react"; +import React, { useContext, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { Link, useNavigate } from "react-router-dom"; @@ -30,6 +30,7 @@ import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component"; import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util"; import DuplicateJob from "./jobs-detail-header-actions.duplicate.util"; import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production"; +import SocketContext from "../../contexts/SocketIO/socketContext.jsx"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -126,6 +127,7 @@ export function JobsDetailHeaderActions({ const [updateJob] = useMutation(UPDATE_JOB); const [voidJob] = useMutation(VOID_JOB); const [cancelAllAppointments] = useMutation(CANCEL_APPOINTMENTS_BY_JOB_ID); + const { socket } = useContext(SocketContext); const { treatments: { ImEXPay } @@ -299,7 +301,8 @@ export function JobsDetailHeaderActions({ if (p && p.isValid()) { openChatByPhone({ phone_num: p.formatInternational(), - jobid: job.id + jobid: job.id, + socket }); setMessage( `${window.location.protocol}//${window.location.host}/csi/${result.data.insert_csi.returning[0].id}` @@ -342,7 +345,8 @@ export function JobsDetailHeaderActions({ if (p && p.isValid()) { openChatByPhone({ phone_num: p.formatInternational(), - jobid: job.id + jobid: job.id, + socket }); setMessage(`${window.location.protocol}//${window.location.host}/csi/${job.csiinvites[0].id}`); } else { diff --git a/client/src/components/payments-generate-link/payments-generate-link.component.jsx b/client/src/components/payments-generate-link/payments-generate-link.component.jsx index b0f4b26b9..f9d84d10f 100644 --- a/client/src/components/payments-generate-link/payments-generate-link.component.jsx +++ b/client/src/components/payments-generate-link/payments-generate-link.component.jsx @@ -3,13 +3,14 @@ import { Button, Form, message, Popover, Space } from "antd"; import axios from "axios"; import Dinero from "dinero.js"; import { parsePhoneNumber } from "libphonenumber-js"; -import React, { useState } from "react"; +import React, { useContext, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component"; +import SocketContext from "../../contexts/SocketIO/socketContext.jsx"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -28,6 +29,7 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [paymentLink, setPaymentLink] = useState(null); + const { socket } = useContext(SocketContext); const handleFinish = async ({ amount }) => { setLoading(true); @@ -50,7 +52,8 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope if (p) { openChatByPhone({ phone_num: p.formatInternational(), - jobid: job.id + jobid: job.id, + socket }); setMessage( t("payments.labels.smspaymentreminder", { @@ -106,7 +109,8 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope const p = parsePhoneNumber(job.ownr_ph1, "CA"); openChatByPhone({ phone_num: p.formatInternational(), - jobid: job.id + jobid: job.id, + socket }); setMessage( t("payments.labels.smspaymentreminder", { diff --git a/client/src/firebase/firebase.utils.js b/client/src/firebase/firebase.utils.js index 46a460525..33da2eeac 100644 --- a/client/src/firebase/firebase.utils.js +++ b/client/src/firebase/firebase.utils.js @@ -4,7 +4,6 @@ import { getAuth, updatePassword, updateProfile } from "firebase/auth"; import { getFirestore } from "firebase/firestore"; import { getMessaging, getToken, onMessage } from "firebase/messaging"; import { store } from "../redux/store"; -import axios from "axios"; const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG); initializeApp(config); diff --git a/client/src/graphql/conversations.queries.js b/client/src/graphql/conversations.queries.js index 03bc0426b..373d70691 100644 --- a/client/src/graphql/conversations.queries.js +++ b/client/src/graphql/conversations.queries.js @@ -59,6 +59,8 @@ export const GET_CONVERSATION_DETAILS = gql` id phone_num archived + updated_at + unreadcnt label job_conversations { jobid @@ -119,6 +121,26 @@ export const CREATE_CONVERSATION = gql` insert_conversations(objects: $conversation) { returning { id + phone_num + archived + label + unreadcnt + job_conversations { + jobid + conversationid + job { + id + ownr_fn + ownr_ln + ownr_co_nm + ro_number + } + } + messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) { + aggregate { + count + } + } } } } diff --git a/client/src/redux/messaging/messaging.sagas.js b/client/src/redux/messaging/messaging.sagas.js index 86757a427..fa17c6db5 100644 --- a/client/src/redux/messaging/messaging.sagas.js +++ b/client/src/redux/messaging/messaging.sagas.js @@ -2,7 +2,13 @@ import axios from "axios"; import parsePhoneNumber from "libphonenumber-js"; import { all, call, put, select, takeLatest } from "redux-saga/effects"; import { logImEXEvent } from "../../firebase/firebase.utils"; -import { CONVERSATION_ID_BY_PHONE, CREATE_CONVERSATION } from "../../graphql/conversations.queries"; +import { + CONVERSATION_ID_BY_PHONE, + CONVERSATION_LIST_QUERY, + CREATE_CONVERSATION, + GET_CONVERSATION_DETAILS, + TOGGLE_CONVERSATION_ARCHIVE +} from "../../graphql/conversations.queries"; import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries"; import client from "../../utils/GraphQLClient"; import { selectBodyshop } from "../user/user.selectors"; @@ -27,23 +33,24 @@ export function* onOpenChatByPhone() { export function* openChatByPhone({ payload }) { logImEXEvent("messaging_open_by_phone"); - const { phone_num, jobid } = payload; - + const { socket, phone_num, jobid } = payload; const p = parsePhoneNumber(phone_num, "CA"); const bodyshop = yield select(selectBodyshop); + try { const { data: { conversations } } = yield client.query({ query: CONVERSATION_ID_BY_PHONE, variables: { phone: p.number }, - fetchPolicy: 'no-cache' + fetchPolicy: "no-cache" }); if (conversations.length === 0) { + // No conversation exists, create a new one const { data: { - insert_conversations: { returning: newConversationsId } + insert_conversations: { returning: newConversations } } } = yield client.mutate({ mutation: CREATE_CONVERSATION, @@ -57,26 +64,107 @@ export function* openChatByPhone({ payload }) { ] } }); - yield put(setSelectedConversation(newConversationsId[0].id)); - } else if (conversations.length === 1) { - //got the ID. Open it. - yield put(setSelectedConversation(conversations[0].id)); - //Check to see if this job ID is already a child of it. If not add the tag. - if (jobid && !conversations[0].job_conversations.find((jc) => jc.jobid === jobid)) + const createdConversation = newConversations[0]; // Get the newly created conversation + + // Emit event for new conversation with full details + if (socket) { + socket.emit("conversation-modified", { + bodyshopId: bodyshop.id, + type: "conversation-created", + ...createdConversation + }); + } + + // Set the newly created conversation as selected + yield put(setSelectedConversation(createdConversation.id)); + } else if (conversations.length === 1) { + const conversation = conversations[0]; + + if (conversation.archived) { + // Conversation is archived, unarchive it in the DB + const { + data: { update_conversations_by_pk: updatedConversation } + } = yield client.mutate({ + mutation: TOGGLE_CONVERSATION_ARCHIVE, + variables: { + id: conversation.id, + archived: false + } + }); + + if (socket) { + socket.emit("conversation-modified", { + type: "conversation-unarchived", + conversationId: updatedConversation.id, + bodyshopId: bodyshop.id, + archived: false + }); + } + + // Update the conversation list in the cache + const existingConversations = client.cache.readQuery({ + query: CONVERSATION_LIST_QUERY, + variables: { offset: 0 } + }); + + client.cache.writeQuery({ + query: CONVERSATION_LIST_QUERY, + variables: { offset: 0 }, + data: { + conversations: [ + { + ...conversation, + archived: false, + updated_at: new Date().toISOString() + }, + ...(existingConversations?.conversations || []) + ] + } + }); + } + + // Check if the conversation exists in the cache + const cacheId = client.cache.identify({ + __typename: "conversations", + id: conversation.id + }); + + if (!cacheId) { + // Fetch the conversation details from the database + const { data } = yield client.query({ + query: GET_CONVERSATION_DETAILS, + variables: { conversationId: conversation.id } + }); + + // Write fetched data to the cache + client.cache.writeQuery({ + query: GET_CONVERSATION_DETAILS, + variables: { conversationId: conversation.id }, + data + }); + } + + // Open the conversation + yield put(setSelectedConversation(conversation.id)); + + // Check and add job tag if needed + if (jobid && !conversation.job_conversations.find((jc) => jc.jobid === jobid)) { yield client.mutate({ mutation: INSERT_CONVERSATION_TAG, variables: { - conversationId: conversations[0].id, + conversationId: conversation.id, jobId: jobid } }); + } } else { - console.log("ERROR: Multiple conversations found. "); + // Multiple conversations found + console.error("ERROR: Multiple conversations found."); yield put(setSelectedConversation(null)); } } catch (error) { - console.log("Error in sendMessage saga.", error); + console.error("Error in openChatByPhone saga.", error); } }