IO-3000 Adjusted first approach at messaging WS changes.

This commit is contained in:
Patrick Fic
2024-11-19 15:52:57 -08:00
parent 289a666b6d
commit 299a675a9c
22 changed files with 1952 additions and 2570 deletions

View File

@@ -2,20 +2,26 @@ import { useApolloClient } from "@apollo/client";
import { getToken, onMessage } from "@firebase/messaging"; import { getToken, onMessage } from "@firebase/messaging";
import { Button, notification, Space } from "antd"; import { Button, notification, Space } from "antd";
import axios from "axios"; import axios from "axios";
import React, { useEffect } from "react"; import React, { useContext, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { messaging, requestForToken } from "../../firebase/firebase.utils"; import { messaging, requestForToken } from "../../firebase/firebase.utils";
import FcmHandler from "../../utils/fcm-handler"; import FcmHandler from "../../utils/fcm-handler";
import ChatPopupComponent from "../chat-popup/chat-popup.component"; import ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss"; import "./chat-affix.styles.scss";
import SocketContext from "../../contexts/SocketIO/socketContext";
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
export function ChatAffixContainer({ bodyshop, chatVisible }) { export function ChatAffixContainer({ bodyshop, chatVisible }) {
const { t } = useTranslation(); const { t } = useTranslation();
const client = useApolloClient(); const client = useApolloClient();
const { socket } = useContext(SocketContext);
useEffect(() => { useEffect(() => {
if (!bodyshop || !bodyshop.messagingservicesid) return; if (!bodyshop || !bodyshop.messagingservicesid) return;
//Register WS handlers
registerMessagingHandlers({ socket, client });
async function SubscribeToTopic() { async function SubscribeToTopic() {
try { try {
const r = await axios.post("/notifications/subscribe", { const r = await axios.post("/notifications/subscribe", {
@@ -61,7 +67,11 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) {
SubscribeToTopic(); SubscribeToTopic();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [bodyshop]);
return () => {
unregisterMessagingHandlers({ socket });
};
}, [bodyshop, socket, t, client]);
useEffect(() => { useEffect(() => {
function handleMessage(payload) { function handleMessage(payload) {

View File

@@ -0,0 +1,136 @@
import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
export function registerMessagingHandlers({ socket, client }) {
if (!(socket && client)) return;
function handleNewMessageSummary(message) {
console.log("🚀 ~ SUMMARY CONSOLE LOG:", message);
if (!message.isoutbound) {
//It's an inbound message.
if (!message.existingConversation) {
//Do a read query.
const queryResults = client.cache.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: {}
});
// Do a write query. Assume 0 unread messages to utilize code below.
client.cache.writeQuery({
query: CONVERSATION_LIST_QUERY,
variables: {},
data: {
conversations: [
{ ...message.newConversation, messages_aggregate: { aggregate: { count: 0 } } },
...queryResults
]
}
});
}
client.cache.modify({
id: client.cache.identify({
__typename: "conversations",
id: message.conversationId
}),
fields: {
updated_at: () => new Date(),
messages_aggregate(cached) {
return { aggregate: { count: cached.aggregate.count + 1 } };
}
}
});
client.cache.modify({
fields: {
conversations(existingConversations = [], { readField }) {
return [
{ __ref: `conversations:${message.conversationId}` }, // TODO: This throws the cache merging error in apollo.
...existingConversations.filter((c) => c.__ref !== `conversations:${message.conversationId}`)
];
}
}
});
client.cache.modify({
fields: {
messages_aggregate(cached) {
return { aggregate: { count: cached.aggregate.count + 1 } };
}
}
});
} else {
//It's an outbound message
//Update the last updated for conversations in the list. If it's new, add it in.
// If it isn't just update the last updated at.
client.cache.modify({
id: client.cache.identify({
__typename: "conversations",
id: message.conversationId
}),
fields: {
updated_at: () => message.newMessage.updated_at
}
});
}
}
function handleNewMessageDetailed(message) {
console.log("🚀 ~ DETAIL CONSOLE LOG:", message);
//They're looking at the conversation right now. Need to merge into the list of messages i.e. append to the end.
//Add the message to the overall cache.
//Handle outbound messages
if (message.newMessage.isoutbound) {
const queryResults = client.cache.readQuery({
query: GET_CONVERSATION_DETAILS,
variables: { conversationId: message.newMessage.conversationid }
});
client.cache.writeQuery({
query: GET_CONVERSATION_DETAILS,
variables: { conversationId: message.newMessage.conversationid },
data: {
...queryResults,
conversations_by_pk: {
...queryResults.conversations_by_pk,
messages: [...queryResults.conversations_by_pk.messages, message.newMessage]
}
}
});
}
// We got this as a receive.
else {
}
}
function handleMessageChanged(message) {
//Find it in the cache, and just update it based on what was sent.
client.cache.modify({
id: client.cache.identify({
__typename: "messages",
id: message.id
}),
fields: {
//TODO: see if there is a way to have this update all fields e.g. only spread in updates rather than prescribing
updated_at: () => new Date(),
status(cached) {
return message.status;
}
}
});
}
function handleConversationChanged(conversation) {
//If it was archived, marked unread, etc.
}
socket.on("new-message-summary", handleNewMessageSummary);
socket.on("new-message-detailed", handleNewMessageDetailed);
socket.on("message-changed", handleMessageChanged);
socket.on("conversation-changed", handleConversationChanged); //TODO: Unread, mark as read, archived, unarchive, etc.
}
export function unregisterMessagingHandlers({ socket }) {
if (!socket) return;
socket.off("new-message-summary");
socket.off("new-message-detailed");
socket.off("message-changed");
socket.off("message-changed");
socket.off("conversation-changed");
}

View File

@@ -1,7 +1,7 @@
import { Badge, Card, List, Space, Tag } from "antd"; import { Badge, Card, List, Space, Tag } from "antd";
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { AutoSizer, CellMeasurer, CellMeasurerCache, List as VirtualizedList } from "react-virtualized"; import { Virtuoso } from "react-virtuoso";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { setSelectedConversation } from "../../redux/messaging/messaging.actions"; import { setSelectedConversation } from "../../redux/messaging/messaging.actions";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors"; import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
@@ -25,12 +25,7 @@ function ChatConversationListComponent({
setSelectedConversation, setSelectedConversation,
loadMoreConversations loadMoreConversations
}) { }) {
const cache = new CellMeasurerCache({ const renderConversation = (index) => {
fixedWidth: true,
defaultHeight: 60
});
const rowRenderer = ({ index, key, style, parent }) => {
const item = conversationList[index]; const item = conversationList[index];
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>; const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
const cardContentLeft = const cardContentLeft =
@@ -52,7 +47,8 @@ function ChatConversationListComponent({
)} )}
</> </>
); );
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count || 0} />;
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count} />;
const getCardStyle = () => const getCardStyle = () =>
item.id === selectedConversation item.id === selectedConversation
@@ -60,40 +56,27 @@ function ChatConversationListComponent({
: { backgroundColor: index % 2 === 0 ? "#f0f2f5" : "#ffffff" }; : { backgroundColor: index % 2 === 0 ? "#f0f2f5" : "#ffffff" };
return ( return (
<CellMeasurer key={key} cache={cache} parent={parent} columnIndex={0} rowIndex={index}> <List.Item
<List.Item key={item.id}
onClick={() => setSelectedConversation(item.id)} onClick={() => setSelectedConversation(item.id)}
style={style} className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
className={`chat-list-item >
${item.id === selectedConversation ? "chat-list-selected-conversation" : null}`} <Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}>
> <div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}> <div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div> </Card>
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div> </List.Item>
</Card>
</List.Item>
</CellMeasurer>
); );
}; };
return ( return (
<div className="chat-list-container"> <div className="chat-list-container">
<AutoSizer> <Virtuoso
{({ height, width }) => ( data={conversationList}
<VirtualizedList itemContent={(index) => renderConversation(index)}
height={height} style={{ height: "100%", width: "100%" }}
width={width} endReached={loadMoreConversations} // Calls loadMoreConversations when scrolled to the bottom
rowCount={conversationList.length} />
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
onScroll={({ scrollTop, scrollHeight, clientHeight }) => {
if (scrollTop + clientHeight === scrollHeight) {
loadMoreConversations();
}
}}
/>
)}
</AutoSizer>
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
.chat-list-container { .chat-list-container {
overflow: hidden; height: 100%; /* Ensure it takes up the full available height */
height: 100%;
border: 1px solid gainsboro; border: 1px solid gainsboro;
overflow: auto; /* Allow scrolling for the Virtuoso component */
} }
.chat-list-item { .chat-list-item {
@@ -14,3 +14,24 @@
color: #ff7a00; color: #ff7a00;
} }
} }
/* Virtuoso item container adjustments */
.chat-list-container > div {
height: 100%; /* Ensure Virtuoso takes full height */
display: flex;
flex-direction: column;
}
/* Add spacing and better alignment for items */
.chat-list-item {
padding: 0.5rem 0; /* Add spacing between list items */
.ant-card {
border-radius: 8px; /* Slight rounding for card edges */
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); /* Subtle shadow for better definition */
}
&:hover .ant-card {
border-color: #ff7a00; /* Highlight border on hover */
}
}

View File

@@ -1,13 +1,14 @@
import { useMutation, useQuery, useSubscription } from "@apollo/client"; import { useMutation, useQuery } from "@apollo/client";
import React, { useState } from "react"; import axios from "axios";
import React, { useEffect, useState, useContext } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries"; import SocketContext from "../../contexts/SocketIO/socketContext";
import { GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import { MARK_MESSAGES_AS_READ_BY_CONVERSATION } from "../../graphql/messages.queries"; import { MARK_MESSAGES_AS_READ_BY_CONVERSATION } from "../../graphql/messages.queries";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors"; import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import ChatConversationComponent from "./chat-conversation.component";
import axios from "axios";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatConversationComponent from "./chat-conversation.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation, selectedConversation: selectSelectedConversation,
@@ -27,41 +28,34 @@ export function ChatConversationContainer({ bodyshop, selectedConversation }) {
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only"
}); });
const { loading, error, data } = useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, { const { socket } = useContext(SocketContext);
variables: { conversationId: selectedConversation }
}); useEffect(() => {
socket.emit("join-bodyshop-conversation", { bodyshopId: bodyshop.id, conversationId: selectedConversation });
return () => {
socket.emit("leave-bodyshop-conversation", { bodyshopId: bodyshop.id, conversationId: selectedConversation });
};
}, [selectedConversation, bodyshop, socket]);
// const { loading, error, data } = useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, {
// variables: { conversationId: selectedConversation }
// });
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false); const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
const [markConversationRead] = useMutation(MARK_MESSAGES_AS_READ_BY_CONVERSATION, {
variables: { conversationId: selectedConversation },
refetchQueries: ["UNREAD_CONVERSATION_COUNT"],
update(cache) {
cache.modify({
id: cache.identify({
__typename: "conversations",
id: selectedConversation
}),
fields: {
messages_aggregate(cached) {
return { aggregate: { count: 0 } };
}
}
});
}
});
const unreadCount = const unreadCount =
data && convoData &&
data.messages && convoData.conversations_by_pk &&
data.messages.reduce((acc, val) => { convoData.conversations_by_pk.messages &&
convoData.conversations_by_pk.messages.reduce((acc, val) => {
return !val.read && !val.isoutbound ? acc + 1 : acc; return !val.read && !val.isoutbound ? acc + 1 : acc;
}, 0); }, 0);
const handleMarkConversationAsRead = async () => { const handleMarkConversationAsRead = async () => {
if (unreadCount > 0 && !!selectedConversation && !markingAsReadInProgress) { if (unreadCount > 0 && !!selectedConversation && !markingAsReadInProgress) {
setMarkingAsReadInProgress(true); setMarkingAsReadInProgress(true);
await markConversationRead({}); // await markConversationRead({});
await axios.post("/sms/markConversationRead", { await axios.post("/sms/markConversationRead", {
conversationid: selectedConversation, conversationid: selectedConversation,
imexshopid: bodyshop.imexshopid imexshopid: bodyshop.imexshopid
@@ -72,9 +66,9 @@ export function ChatConversationContainer({ bodyshop, selectedConversation }) {
return ( return (
<ChatConversationComponent <ChatConversationComponent
subState={[loading || convoLoading, error || convoError]} subState={[convoLoading, convoError]}
conversation={convoData ? convoData.conversations_by_pk : {}} conversation={convoData ? convoData.conversations_by_pk : {}}
messages={data ? data.messages : []} messages={convoData ? convoData.conversations_by_pk.messages : []}
handleMarkConversationAsRead={handleMarkConversationAsRead} handleMarkConversationAsRead={handleMarkConversationAsRead}
/> />
); );

View File

@@ -2,105 +2,90 @@ import Icon from "@ant-design/icons";
import { Tooltip } from "antd"; import { Tooltip } from "antd";
import i18n from "i18next"; import i18n from "i18next";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import React, { useEffect, useRef } from "react"; import React, { useRef, useEffect } from "react";
import { MdDone, MdDoneAll } from "react-icons/md"; import { MdDone, MdDoneAll } from "react-icons/md";
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from "react-virtualized"; import { Virtuoso } from "react-virtuoso";
import { DateTimeFormatter } from "../../utils/DateFormatter"; import { DateTimeFormatter } from "../../utils/DateFormatter";
import "./chat-message-list.styles.scss"; import "./chat-message-list.styles.scss";
export default function ChatMessageListComponent({ messages }) { export default function ChatMessageListComponent({ messages }) {
const virtualizedListRef = useRef(null); const virtuosoRef = useRef(null);
const _cache = new CellMeasurerCache({ // Scroll to the bottom after a short delay when the component mounts
fixedWidth: true, useEffect(() => {
// minHeight: 50, const timer = setTimeout(() => {
defaultHeight: 100 if (virtuosoRef.current) {
}); virtuosoRef.current.scrollToIndex({
index: messages.length - 1,
behavior: "auto" // Instantly scroll to the bottom
});
}
}, 100); // Delay of 100ms to allow rendering
return () => clearTimeout(timer); // Cleanup the timer on unmount
}, [messages.length]); // Run only once on component mount
const scrollToBottom = (renderedrows) => { // Scroll to the bottom after the new messages are rendered
//console.log("Scrolling to", messages.length); useEffect(() => {
// !!virtualizedListRef.current && if (virtuosoRef.current) {
// virtualizedListRef.current.scrollToRow(messages.length); // Allow the DOM and Virtuoso to fully render the new data
// Outstanding isue on virtualization: https://github.com/bvaughn/react-virtualized/issues/1179 setTimeout(() => {
//Scrolling does not work on this version of React. virtuosoRef.current.scrollToIndex({
}; index: messages.length - 1,
align: "end", // Ensure the last message is fully visible
useEffect(scrollToBottom, [messages]); behavior: "smooth" // Smooth scrolling
});
const _rowRenderer = ({ index, key, parent, style }) => { }, 50); // Slight delay to ensure layout recalculates
}
}, [messages]); // Triggered when new messages are added
//TODO: Does this one need to come into the render of the method?
const renderMessage = (index) => {
const message = messages[index];
return ( return (
<CellMeasurer cache={_cache} key={key} rowIndex={index} parent={parent}> <div key={index} className={`${message.isoutbound ? "mine messages" : "yours messages"}`}>
{({ measure, registerChild }) => ( <div className="message msgmargin">
<div <Tooltip title={DateTimeFormatter({ children: message.created_at })}>
ref={registerChild} <div>
onLoad={measure} {message.image_path &&
style={style} message.image_path.map((i, idx) => (
className={`${messages[index].isoutbound ? "mine messages" : "yours messages"}`} <div key={idx} style={{ display: "flex", justifyContent: "center" }}>
> <a href={i} target="__blank" rel="noopener noreferrer">
<div className="message msgmargin"> <img alt="Received" className="message-img" src={i} />
{MessageRender(messages[index])} </a>
{StatusRender(messages[index].status)} </div>
))}
<div>{message.text}</div>
</div> </div>
{messages[index].isoutbound && ( </Tooltip>
<div style={{ fontSize: 10 }}> {message.status && (
{i18n.t("messaging.labels.sentby", { <div className="message-status">
by: messages[index].userid, <Icon
time: dayjs(messages[index].created_at).format("MM/DD/YYYY @ hh:mm a") component={message.status === "sent" ? MdDone : message.status === "delivered" ? MdDoneAll : null}
})} className="message-icon"
</div> />
)} </div>
)}
</div>
{message.isoutbound && (
<div style={{ fontSize: 10 }}>
{i18n.t("messaging.labels.sentby", {
by: message.userid,
time: dayjs(message.created_at).format("MM/DD/YYYY @ hh:mm a")
})}
</div> </div>
)} )}
</CellMeasurer> </div>
); );
}; };
return ( return (
<div className="chat"> <div className="chat">
<AutoSizer> <Virtuoso
{({ height, width }) => ( ref={virtuosoRef}
<List data={messages}
ref={virtualizedListRef} itemContent={(index) => renderMessage(index)}
width={width} followOutput="smooth" // Ensure smooth scrolling when new data is appended
height={height} style={{ height: "100%", width: "100%" }}
rowHeight={_cache.rowHeight} />
rowRenderer={_rowRenderer}
rowCount={messages.length}
overscanRowCount={10}
estimatedRowSize={150}
scrollToIndex={messages.length}
/>
)}
</AutoSizer>
</div> </div>
); );
} }
const MessageRender = (message) => {
return (
<Tooltip title={DateTimeFormatter({ children: message.created_at })}>
<div>
{message.image_path &&
message.image_path.map((i, idx) => (
<div key={idx} style={{ display: "flex", justifyContent: "center" }}>
<a href={i} target="__blank">
<img alt="Received" className="message-img" src={i} />
</a>
</div>
))}
<div>{message.text}</div>
</div>
</Tooltip>
);
};
const StatusRender = (status) => {
switch (status) {
case "sent":
return <Icon component={MdDone} className="message-icon" />;
case "delivered":
return <Icon component={MdDoneAll} className="message-icon" />;
default:
return null;
}
};

View File

@@ -1,37 +1,30 @@
.message-icon { .message-icon {
//position: absolute;
// bottom: 0rem;
color: whitesmoke; color: whitesmoke;
border: #000000; border: #000000;
position: absolute; position: absolute;
margin: 0 0.1rem; margin: 0 0.1rem;
bottom: 0.1rem; bottom: 0.1rem;
right: 0.3rem; right: 0.3rem;
z-index: 5; z-index: 5;
} }
.chat { .chat {
flex: 1; flex: 1;
//width: 300px;
//border: solid 1px #eee;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin: 0.8rem 0rem; margin: 0.8rem 0rem;
overflow: hidden; // Ensure the content scrolls correctly
} }
.messages { .messages {
//margin-top: 30px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0.5rem; // Add padding to avoid edge clipping
} }
.message { .message {
border-radius: 20px; border-radius: 20px;
padding: 0.25rem 0.8rem; padding: 0.25rem 0.8rem;
//margin-top: 5px;
// margin-bottom: 5px;
//display: inline-block;
.message-img { .message-img {
max-width: 10rem; max-width: 10rem;
@@ -56,7 +49,7 @@
position: relative; position: relative;
} }
.yours .message.last:before { .yours .message:last-child:before {
content: ""; content: "";
position: absolute; position: absolute;
z-index: 0; z-index: 0;
@@ -68,7 +61,7 @@
border-bottom-right-radius: 15px; border-bottom-right-radius: 15px;
} }
.yours .message.last:after { .yours .message:last-child:after {
content: ""; content: "";
position: absolute; position: absolute;
z-index: 1; z-index: 1;
@@ -88,12 +81,11 @@
color: white; color: white;
margin-left: 25%; margin-left: 25%;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%); background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
background-attachment: fixed;
position: relative; position: relative;
padding-bottom: 0.6rem; padding-bottom: 0.6rem;
} }
.mine .message.last:before { .mine .message:last-child:before {
content: ""; content: "";
position: absolute; position: absolute;
z-index: 0; z-index: 0;
@@ -102,11 +94,10 @@
height: 20px; height: 20px;
width: 20px; width: 20px;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%); background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
background-attachment: fixed;
border-bottom-left-radius: 15px; border-bottom-left-radius: 15px;
} }
.mine .message.last:after { .mine .message:last-child:after {
content: ""; content: "";
position: absolute; position: absolute;
z-index: 1; z-index: 1;

View File

@@ -41,6 +41,7 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
const fcmToken = sessionStorage.getItem("fcmtoken"); const fcmToken = sessionStorage.getItem("fcmtoken");
//TODO: Change to be a fallback incase sockets shit the bed
useEffect(() => { useEffect(() => {
if (fcmToken) { if (fcmToken) {
setpollInterval(0); setpollInterval(0);

View File

@@ -3,10 +3,12 @@ import SocketIO from "socket.io-client";
import { auth } from "../../firebase/firebase.utils"; import { auth } from "../../firebase/firebase.utils";
import { store } from "../../redux/store"; import { store } from "../../redux/store";
import { addAlerts, setWssStatus } from "../../redux/application/application.actions"; import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
import { useDispatch } from "react-redux";
const useSocket = (bodyshop) => { const useSocket = (bodyshop) => {
const socketRef = useRef(null); const socketRef = useRef(null);
const [clientId, setClientId] = useState(null); const [clientId, setClientId] = useState(null);
const dispatch = useDispatch();
useEffect(() => { useEffect(() => {
const unsubscribe = auth.onIdTokenChanged(async (user) => { const unsubscribe = auth.onIdTokenChanged(async (user) => {
@@ -38,6 +40,8 @@ const useSocket = (bodyshop) => {
case "alert-update": case "alert-update":
store.dispatch(addAlerts(message.payload)); store.dispatch(addAlerts(message.payload));
break; break;
default:
break;
} }
if (!import.meta.env.DEV) return; if (!import.meta.env.DEV) return;
@@ -45,14 +49,12 @@ const useSocket = (bodyshop) => {
}; };
const handleConnect = () => { const handleConnect = () => {
console.log("Socket connected:", socketInstance.id);
socketInstance.emit("join-bodyshop-room", bodyshop.id); socketInstance.emit("join-bodyshop-room", bodyshop.id);
setClientId(socketInstance.id); setClientId(socketInstance.id);
store.dispatch(setWssStatus("connected")); store.dispatch(setWssStatus("connected"));
}; };
const handleReconnect = (attempt) => { const handleReconnect = (attempt) => {
console.log(`Socket reconnected after ${attempt} attempts`);
store.dispatch(setWssStatus("connected")); store.dispatch(setWssStatus("connected"));
}; };
@@ -62,10 +64,10 @@ const useSocket = (bodyshop) => {
}; };
const handleDisconnect = () => { const handleDisconnect = () => {
console.log("Socket disconnected");
store.dispatch(setWssStatus("disconnected")); store.dispatch(setWssStatus("disconnected"));
}; };
//TODO: Check these handlers.
socketInstance.on("connect", handleConnect); socketInstance.on("connect", handleConnect);
socketInstance.on("reconnect", handleReconnect); socketInstance.on("reconnect", handleReconnect);
socketInstance.on("connect_error", handleConnectionError); socketInstance.on("connect_error", handleConnectionError);
@@ -89,7 +91,7 @@ const useSocket = (bodyshop) => {
socketRef.current = null; socketRef.current = null;
} }
}; };
}, [bodyshop]); }, [bodyshop, dispatch]);
return { socket: socketRef.current, clientId }; return { socket: socketRef.current, clientId };
}; };

View File

@@ -71,6 +71,17 @@ export const GET_CONVERSATION_DETAILS = gql`
ro_number ro_number
} }
} }
messages(order_by: { created_at: asc_nulls_first }) {
id
status
text
isoutbound
image
image_path
userid
created_at
read
}
} }
} }
`; `;
@@ -114,3 +125,22 @@ export const UPDATE_CONVERSATION_LABEL = gql`
} }
} }
`; `;
export const GET_CONVERSATION_MESSAGES = gql`
query GET_CONVERSATION_MESSAGES($conversationId: uuid!) {
conversation: conversations_by_pk(id: $conversationId) {
id
phone_num
updated_at
label
}
messages: messages(where: { conversationid: { _eq: $conversationId } }, order_by: { created_at: asc }) {
id
text
created_at
read
isoutbound
userid
}
}
`;

View File

@@ -145,7 +145,7 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
}; };
fetchAlerts(); fetchAlerts();
}, []); }, [setAlerts]);
// Use useEffect to watch for new alerts // Use useEffect to watch for new alerts
useEffect(() => { useEffect(() => {
@@ -647,7 +647,7 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
return ( return (
<> <>
{import.meta.env.PROD && <ChatAffixContainer bodyshop={bodyshop} chatVisible={chatVisible} />} {true && <ChatAffixContainer bodyshop={bodyshop} chatVisible={chatVisible} />}
<Layout style={{ minHeight: "100vh" }} className="layout-container"> <Layout style={{ minHeight: "100vh" }} className="layout-container">
<UpdateAlert /> <UpdateAlert />
<HeaderContainer /> <HeaderContainer />

View File

@@ -1,70 +1,70 @@
export default async function FcmHandler({ client, payload }) { export default async function FcmHandler({ client, payload }) {
console.log("FCM", payload); console.log("FCM", payload);
switch (payload.type) { // switch (payload.type) {
case "messaging-inbound": // case "messaging-inbound":
client.cache.modify({ // client.cache.modify({
id: client.cache.identify({ // id: client.cache.identify({
__typename: "conversations", // __typename: "conversations",
id: payload.conversationid // id: payload.conversationid
}), // }),
fields: { // fields: {
messages_aggregate(cached) { // messages_aggregate(cached) {
return { aggregate: { count: cached.aggregate.count + 1 } }; // return { aggregate: { count: cached.aggregate.count + 1 } };
} // }
} // }
}); // });
client.cache.modify({ // client.cache.modify({
fields: { // fields: {
messages_aggregate(cached) { // messages_aggregate(cached) {
return { aggregate: { count: cached.aggregate.count + 1 } }; // return { aggregate: { count: cached.aggregate.count + 1 } };
} // }
} // }
}); // });
break; // break;
case "messaging-outbound": // case "messaging-outbound":
client.cache.modify({ // client.cache.modify({
id: client.cache.identify({ // id: client.cache.identify({
__typename: "conversations", // __typename: "conversations",
id: payload.conversationid // id: payload.conversationid
}), // }),
fields: { // fields: {
updated_at(oldupdated0) { // updated_at(oldupdated0) {
return new Date(); // return new Date();
} // }
// messages_aggregate(cached) { // // messages_aggregate(cached) {
// return { aggregate: { count: cached.aggregate.count + 1 } }; // // return { aggregate: { count: cached.aggregate.count + 1 } };
// }, // // },
} // }
}); // });
break; // break;
case "messaging-mark-conversation-read": // case "messaging-mark-conversation-read":
let previousUnreadCount = 0; // let previousUnreadCount = 0;
client.cache.modify({ // client.cache.modify({
id: client.cache.identify({ // id: client.cache.identify({
__typename: "conversations", // __typename: "conversations",
id: payload.conversationid // id: payload.conversationid
}), // }),
fields: { // fields: {
messages_aggregate(cached) { // messages_aggregate(cached) {
previousUnreadCount = cached.aggregate.count; // previousUnreadCount = cached.aggregate.count;
return { aggregate: { count: 0 } }; // return { aggregate: { count: 0 } };
} // }
} // }
}); // });
client.cache.modify({ // client.cache.modify({
fields: { // fields: {
messages_aggregate(cached) { // messages_aggregate(cached) {
return { // return {
aggregate: { // aggregate: {
count: cached.aggregate.count - previousUnreadCount // count: cached.aggregate.count - previousUnreadCount
} // }
}; // };
} // }
} // }
}); // });
break; // break;
default: // default:
console.log("No payload type set."); // console.log("No payload type set.");
break; // break;
} // }
} }

View File

@@ -1,8 +1,5 @@
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { promises as fsPromises } from "fs"; import { promises as fsPromises } from "fs";
import { createRequire } from "module";
import * as path from "path";
import * as url from "url";
import { createLogger, defineConfig } from "vite"; import { createLogger, defineConfig } from "vite";
import { ViteEjsPlugin } from "vite-plugin-ejs"; import { ViteEjsPlugin } from "vite-plugin-ejs";
import eslint from "vite-plugin-eslint"; import eslint from "vite-plugin-eslint";
@@ -18,28 +15,6 @@ process.env.VITE_APP_GIT_SHA_DATE = new Date().toLocaleString("en-US", {
const getFormattedTimestamp = () => const getFormattedTimestamp = () =>
new Date().toLocaleTimeString("en-US", { hour12: true }).replace("AM", "a.m.").replace("PM", "p.m."); new Date().toLocaleTimeString("en-US", { hour12: true }).replace("AM", "a.m.").replace("PM", "p.m.");
/** This is a hack around react-virtualized, should be removed when switching to react-virtuoso */
const WRONG_CODE = `import { bpfrpt_proptype_WindowScroller } from "../WindowScroller.js";`;
function reactVirtualizedFix() {
return {
name: "flat:react-virtualized",
configResolved: async () => {
const require = createRequire(import.meta.url);
const reactVirtualizedPath = require.resolve("react-virtualized");
const { pathname: reactVirtualizedFilePath } = new url.URL(reactVirtualizedPath, import.meta.url);
const file = reactVirtualizedFilePath.replace(
path.join("dist", "commonjs", "index.js"),
path.join("dist", "es", "WindowScroller", "utils", "onScroll.js")
);
const code = await fsPromises.readFile(file, "utf-8");
const modified = code.replace(WRONG_CODE, "");
await fsPromises.writeFile(file, modified);
}
};
}
/** End of hack */
export const logger = createLogger("info", { export const logger = createLogger("info", {
allowClearScreen: false allowClearScreen: false
}); });
@@ -108,7 +83,6 @@ export default defineConfig({
gcm_sender_id: "103953800507" gcm_sender_id: "103953800507"
} }
}), }),
reactVirtualizedFix(),
react(), react(),
eslint() eslint()
], ],

3234
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,13 +19,13 @@
"makeitpretty": "prettier --write \"**/*.{css,js,json,jsx,scss}\"" "makeitpretty": "prettier --write \"**/*.{css,js,json,jsx,scss}\""
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.679.0", "@aws-sdk/client-cloudwatch-logs": "^3.693.0",
"@aws-sdk/client-elasticache": "^3.675.0", "@aws-sdk/client-elasticache": "^3.693.0",
"@aws-sdk/client-s3": "^3.689.0", "@aws-sdk/client-s3": "^3.693.0",
"@aws-sdk/client-secrets-manager": "^3.675.0", "@aws-sdk/client-secrets-manager": "^3.693.0",
"@aws-sdk/client-ses": "^3.675.0", "@aws-sdk/client-ses": "^3.693.0",
"@aws-sdk/credential-provider-node": "^3.675.0", "@aws-sdk/credential-provider-node": "^3.693.0",
"@opensearch-project/opensearch": "^2.12.0", "@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1", "@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"aws4": "^1.13.2", "aws4": "^1.13.2",
@@ -34,20 +34,20 @@
"bluebird": "^3.7.2", "bluebird": "^3.7.2",
"body-parser": "^1.20.3", "body-parser": "^1.20.3",
"canvas": "^2.11.2", "canvas": "^2.11.2",
"chart.js": "^4.4.5", "chart.js": "^4.4.6",
"cloudinary": "^2.5.1", "cloudinary": "^2.5.1",
"compression": "^1.7.4", "compression": "^1.7.5",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "2.8.5", "cors": "2.8.5",
"csrf": "^3.1.0", "csrf": "^3.1.0",
"dinero.js": "^1.9.1", "dinero.js": "^1.9.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.1", "express": "^4.21.1",
"firebase-admin": "^12.6.0", "firebase-admin": "^13.0.0",
"graphql": "^16.9.0", "graphql": "^16.9.0",
"graphql-request": "^6.1.0", "graphql-request": "^6.1.0",
"inline-css": "^4.0.2", "inline-css": "^4.0.2",
"intuit-oauth": "^4.1.2", "intuit-oauth": "^4.1.3",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"json-2-csv": "^5.5.6", "json-2-csv": "^5.5.6",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@@ -56,18 +56,18 @@
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-mailjet": "^6.0.6", "node-mailjet": "^6.0.6",
"node-persist": "^4.0.3", "node-persist": "^4.0.3",
"nodemailer": "^6.9.15", "nodemailer": "^6.9.16",
"phone": "^3.1.51", "phone": "^3.1.53",
"recursive-diff": "^1.0.9", "recursive-diff": "^1.0.9",
"redis": "^4.7.0", "redis": "^4.7.0",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"soap": "^1.1.5", "soap": "^1.1.6",
"socket.io": "^4.8.0", "socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5", "socket.io-adapter": "^2.5.5",
"ssh2-sftp-client": "^10.0.3", "ssh2-sftp-client": "^10.0.3",
"twilio": "^4.23.0", "twilio": "^4.23.0",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"winston": "^3.15.0", "winston": "^3.17.0",
"winston-cloudwatch": "^6.3.0", "winston-cloudwatch": "^6.3.0",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"xmlbuilder2": "^3.1.1" "xmlbuilder2": "^3.1.1"

View File

@@ -87,6 +87,21 @@ mutation RECEIVE_MESSAGE($msg: [messages_insert_input!]!) {
updated_at updated_at
unreadcnt unreadcnt
phone_num phone_num
label
job_conversations {
job {
id
ro_number
ownr_fn
ownr_ln
ownr_co_nm
}
}
messages_aggregate (where: { read: { _eq: false }, isoutbound: { _eq: false } }){
aggregate {
count
}
}
} }
conversationid conversationid
created_at created_at
@@ -116,6 +131,7 @@ mutation INSERT_MESSAGE($msg: [messages_insert_input!]!, $conversationid: uuid!)
id id
archived archived
bodyshop { bodyshop {
id
imexshopid imexshopid
} }
created_at created_at
@@ -144,6 +160,11 @@ mutation UPDATE_MESSAGE($msid: String!, $fields: messages_set_input!) {
update_messages(where: { msid: { _eq: $msid } }, _set: $fields) { update_messages(where: { msid: { _eq: $msid } }, _set: $fields) {
returning { returning {
id id
status
conversationid
conversation{
bodyshopid
}
} }
} }
}`; }`;
@@ -2544,3 +2565,69 @@ exports.GET_JOBS_BY_PKS = `query GET_JOBS_BY_PKS($ids: [uuid!]!) {
} }
} }
`; `;
exports.GET_CONVERSATIONS = `query GET_CONVERSATIONS($bodyshopId: uuid!) {
conversations(
where: { bodyshopid: { _eq: $bodyshopId }, archived: { _eq: false } },
order_by: { updated_at: desc },
limit: 50
) {
phone_num
id
updated_at
unreadcnt
archived
label
messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
aggregate {
count
}
}
job_conversations {
job {
id
ro_number
ownr_fn
ownr_ln
ownr_co_nm
}
}
}
}
`;
exports.MARK_MESSAGES_AS_READ = `mutation MARK_MESSAGES_AS_READ($conversationId: uuid!) {
update_messages(where: { conversationid: { _eq: $conversationId } }, _set: { read: true }) {
affected_rows
}
}
`;
exports.GET_CONVERSATION_DETAILS = `
query GET_CONVERSATION_DETAILS($conversationId: uuid!) {
conversation: conversations_by_pk(id: $conversationId) {
id
phone_num
updated_at
label
job_conversations {
job {
id
ro_number
ownr_fn
ownr_ln
ownr_co_nm
}
}
}
messages: messages(where: { conversationid: { _eq: $conversationId } }, order_by: { created_at: asc }) {
id
text
created_at
read
isoutbound
userid
image_path
}
}
`;

View File

@@ -13,6 +13,7 @@ const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLCl
const { taskAssignedEmail, tasksRemindEmail } = require("../email/tasksEmails"); const { taskAssignedEmail, tasksRemindEmail } = require("../email/tasksEmails");
const { canvastest } = require("../render/canvas-handler"); const { canvastest } = require("../render/canvas-handler");
const { alertCheck } = require("../alerts/alertcheck"); const { alertCheck } = require("../alerts/alertcheck");
const uuid = require("uuid").v4;
//Test route to ensure Express is responding. //Test route to ensure Express is responding.
router.get("/test", eventAuthorizationMiddleware, async function (req, res) { router.get("/test", eventAuthorizationMiddleware, async function (req, res) {
@@ -57,6 +58,59 @@ router.get("/test-logs", eventAuthorizationMiddleware, (req, res) => {
return res.status(500).send("Logs tested."); return res.status(500).send("Logs tested.");
}); });
router.get("/wstest", eventAuthorizationMiddleware, (req, res) => {
const { ioRedis } = req;
ioRedis.to(`bodyshop-broadcast-room:bfec8c8c-b7f1-49e0-be4c-524455f4e582`).emit("new-message-summary", {
isoutbound: true,
conversationId: "2b44d692-a9e4-4ed4-9c6b-7d8b0c44a0f6",
msid: "SM5d053957bc0da29399b768c23bffcc0f",
summary: true
});
// TODO: Do we need to add more content here?
ioRedis
.to(`bodyshop-conversation-room:bfec8c8c-b7f1-49e0-be4c-524455f4e582:2b44d692-a9e4-4ed4-9c6b-7d8b0c44a0f6`)
.emit("new-message-detailed", {
//
// msid: "SMbbd7703a898fef7f2c07c148ade8a6cd",
// text: "test2",
// conversationid: "2b44d692-a9e4-4ed4-9c6b-7d8b0c44a0f6",
// isoutbound: true,
// userid: "patrick@imex.dev",
// image: false,
// image_path: [],
newMessage: {
conversation: {
id: uuid(),
archived: false,
bodyshop: {
id: "bfec8c8c-b7f1-49e0-be4c-524455f4e582",
imexshopid: "APPLE"
},
created_at: "2024-11-19T19:46:38.984633+00:00",
updated_at: "2024-11-19T22:40:48.346875+00:00",
unreadcnt: 0,
phone_num: "+16138676684"
},
conversationid: "2b44d692-a9e4-4ed4-9c6b-7d8b0c44a0f6",
created_at: "2024-11-19T22:40:48.346875+00:00",
id: "68604ea9-c411-43ec-ab83-899868e58819",
image_path: [],
image: false,
isoutbound: true,
msid: "SMbbd7703a898fef7f2c07c148ade8a6cd",
read: false,
text: `This is a test ${Math.round(Math.random() * 100)}`,
updated_at: "2024-11-19T22:40:48.346875+00:00",
status: "posted",
userid: "patrick@imex.dev"
},
conversationId: "2b44d692-a9e4-4ed4-9c6b-7d8b0c44a0f6",
summary: false
}); // TODO: Do we need to add more content here?
return res.status(500).send("Logs tested.");
});
// Search // Search
router.post("/search", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, os.search); router.post("/search", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, os.search);

View File

@@ -11,7 +11,11 @@ const logger = require("../utils/logger");
const InstanceManager = require("../utils/instanceMgr").default; const InstanceManager = require("../utils/instanceMgr").default;
exports.receive = async (req, res) => { exports.receive = async (req, res) => {
//Perform request validation // Perform request validation
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
logger.log("sms-inbound", "DEBUG", "api", null, { logger.log("sms-inbound", "DEBUG", "api", null, {
msid: req.body.SmsMessageSid, msid: req.body.SmsMessageSid,
@@ -20,7 +24,7 @@ exports.receive = async (req, res) => {
image_path: generateMediaArray(req.body) image_path: generateMediaArray(req.body)
}); });
if (!!!req.body || !!!req.body.MessagingServiceSid || !!!req.body.SmsMessageSid) { if (!req.body || !req.body.MessagingServiceSid || !req.body.SmsMessageSid) {
logger.log("sms-inbound-error", "ERROR", "api", null, { logger.log("sms-inbound-error", "ERROR", "api", null, {
msid: req.body.SmsMessageSid, msid: req.body.SmsMessageSid,
text: req.body.Body, text: req.body.Body,
@@ -28,170 +32,195 @@ exports.receive = async (req, res) => {
image_path: generateMediaArray(req.body), image_path: generateMediaArray(req.body),
type: "malformed-request" type: "malformed-request"
}); });
res.status(400); res.status(400).json({ success: false, error: "Malformed Request" });
res.json({ success: false, error: "Malformed Request" }); return;
} else { }
try {
const response = await client.request(queries.FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, {
mssid: req.body.MessagingServiceSid,
phone: phone(req.body.From).phoneNumber
});
let newMessage = { try {
msid: req.body.SmsMessageSid, const response = await client.request(queries.FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, {
text: req.body.Body, mssid: req.body.MessagingServiceSid,
image: !!req.body.MediaUrl0, phone: phone(req.body.From).phoneNumber
image_path: generateMediaArray(req.body) });
};
if (response.bodyshops[0]) { let newMessage = {
//Found a bodyshop - should always happen. msid: req.body.SmsMessageSid,
if (response.bodyshops[0].conversations.length === 0) { text: req.body.Body,
//No conversation Found, create one. image: !!req.body.MediaUrl0,
//console.log("[SMS Receive] No conversation found. Creating one."); image_path: generateMediaArray(req.body)
newMessage.conversation = { };
data: {
bodyshopid: response.bodyshops[0].id, if (response.bodyshops[0]) {
phone_num: phone(req.body.From).phoneNumber if (response.bodyshops[0].conversations.length === 0) {
} // No conversation found, create one
}; newMessage.conversation = {
} else if (response.bodyshops[0].conversations.length === 1) { data: {
//Just add it to the conversation bodyshopid: response.bodyshops[0].id,
//console.log("[SMS Receive] Conversation found. Added ID."); phone_num: phone(req.body.From).phoneNumber
newMessage.conversationid = response.bodyshops[0].conversations[0].id;
} else {
//We should never get here.
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,
type: "duplicate-phone"
});
}
try {
let insertresp;
if (response.bodyshops[0].conversations[0]) {
insertresp = await client.request(queries.INSERT_MESSAGE, {
msg: newMessage,
conversationid: response.bodyshops[0].conversations[0] && response.bodyshops[0].conversations[0].id
});
} else {
insertresp = await client.request(queries.RECEIVE_MESSAGE, {
msg: newMessage
});
} }
const message = insertresp.insert_messages.returning[0]; };
const data = {
type: "messaging-inbound",
conversationid: message.conversationid || "",
text: message.text || "",
messageid: message.id || "",
phone_num: message.conversation.phone_num || ""
};
const fcmresp = await admin.messaging().send({ try {
topic: `${message.conversation.bodyshop.imexshopid}-messaging`, // Insert new conversation and message
notification: { const insertresp = await client.request(queries.RECEIVE_MESSAGE, { msg: newMessage });
title: InstanceManager({
imex: `ImEX Online Message - ${data.phone_num}`, // Safely access conversation and message
rome: `Rome Online Message - ${data.phone_num}`, const createdConversation = insertresp?.insert_messages?.returning?.[0]?.conversation || null;
promanager: `ProManager Message - ${data.phone_num}` const message = insertresp?.insert_messages?.returning?.[0];
}),
body: message.image_path ? `Image ${message.text}` : message.text if (!createdConversation) {
//imageUrl: "https://thinkimex.com/img/io-fcm.png", //TODO:AIO Resolve addresses for other instances throw new Error("Conversation data is missing from the response.");
}, }
data
const broadcastRoom = getBodyshopRoom(r2.insert_messages.returning[0].conversation.bodyshop.id);
const conversationRoom = getBodyshopConversationRoom({
bodyshopId: message.conversation.bodyshop.id,
conversationId: message.conversation.id
});
// Broadcast new message to the conversation room
ioRedis.to(broadcastRoom).emit("new-message-summary", {
isoutbound: false,
existingConversation: false,
newConversation: createdConversation,
conversationId: createdConversation.id,
updated_at: message.updated_at,
msid: message.sid,
summary: true
});
ioRedis.to(conversationRoom).emit("new-message-detailed", {
newMessage: message,
isoutbound: false,
newConversation: createdConversation,
existingConversation: false,
conversationId: createdConversation.id,
summary: false
}); });
logger.log("sms-inbound-success", "DEBUG", "api", null, { logger.log("sms-inbound-success", "DEBUG", "api", null, {
newMessage, newMessage,
fcmresp createdConversation
}); });
res.status(200).send(""); res.status(200).send("");
} catch (e2) { return;
} catch (e) {
logger.log("sms-inbound-error", "ERROR", "api", null, { logger.log("sms-inbound-error", "ERROR", "api", null, {
msid: req.body.SmsMessageSid, msid: req.body.SmsMessageSid,
text: req.body.Body, text: req.body.Body,
image: !!req.body.MediaUrl0, image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body), image_path: generateMediaArray(req.body),
messagingServiceSid: req.body.MessagingServiceSid, messagingServiceSid: req.body.MessagingServiceSid,
error: e2 error: e
}); });
res.sendStatus(500).json(e2); res.status(500).json(e);
return;
} }
} else if (response.bodyshops[0].conversations.length === 1) {
// Add to the existing conversation
// conversation UPDATED
newMessage.conversationid = response.bodyshops[0].conversations[0].id;
} else {
// Duplicate phone error
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,
type: "duplicate-phone"
});
res.status(400).json({ success: false, error: "Duplicate phone number" });
return;
}
try {
// Insert message into an existing conversation
const insertresp = await client.request(queries.INSERT_MESSAGE, {
msg: newMessage,
conversationid: newMessage.conversationid
});
const message = insertresp.insert_messages.returning[0];
const data = {
type: "messaging-inbound",
conversationid: message.conversationid || "",
text: message.text || "",
messageid: message.id || "",
phone_num: message.conversation.phone_num || ""
};
const fcmresp = await admin.messaging().send({
topic: `${message.conversation.bodyshop.imexshopid}-messaging`,
notification: {
title: InstanceManager({
imex: `ImEX Online Message - ${data.phone_num}`,
rome: `Rome Online Message - ${data.phone_num}`,
promanager: `ProManager Message - ${data.phone_num}`
}),
body: message.image_path ? `Image ${message.text}` : message.text
},
data
});
logger.log("sms-inbound-success", "DEBUG", "api", null, {
newMessage,
fcmresp
});
const broadcastRoom = getBodyshopRoom(r2.insert_messages.returning[0].conversation.bodyshop.id);
const conversationRoom = getBodyshopConversationRoom({
bodyshopId: message.conversation.bodyshop.id,
conversationId: message.conversation.id
});
// Broadcast new message to the conversation room
ioRedis.to(broadcastRoom).emit("new-message-summary", {
isoutbound: false,
existingConversation: true,
conversationId: conversationid,
updated_at: message.updated_at,
msid: message.sid,
summary: true
});
ioRedis.to(conversationRoom).emit("new-message-detailed", {
newMessage: message,
isoutbound: false,
existingConversation: true,
conversationId: conversationid,
summary: false
});
res.status(200).send("");
} catch (e) {
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,
error: e
});
res.status(500).json(e);
} }
} catch (e1) {
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,
error: e1
});
res.sendStatus(500).json(e1);
} }
} catch (e) {
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,
error: e
});
res.status(500).json(e);
} }
}; };
// const sampleMessage: {
// "ToCountry": "CA",
// "ToState": "BC",
// "SmsMessageSid": "SMad7bddaf3454c0904999d6018b1e8f49",
// "NumMedia": "0",
// "ToCity": "Vancouver",
// "FromZip": "",
// "SmsSid": "SMad7bddaf3454c0904999d6018b1e8f49",
// "FromState": "BC",
// "SmsStatus": "received",
// "FromCity": "VANCOUVER",
// "Body": "Hi",
// "FromCountry": "CA",
// "To": "+16043301606",
// "MessagingServiceSid": "MG6e259e2add04ffa0d0aa355038670ee1",
// "ToZip": "",
// "NumSegments": "1",
// "MessageSid": "SMad7bddaf3454c0904999d6018b1e8f49",
// "AccountSid": "AC6c09d337d6b9c68ab6488c2052bd457c",
// "From": "+16049992002",
// "ApiVersion": "2010-04-01"
// }
// ] req.body {
// [0] ToCountry: 'CA',
// [0] MediaContentType0: 'image/jpeg',
// [0] ToState: 'BC',
// [0] SmsMessageSid: 'MM14fa2851ba26e0dc2b62073f8e7cdf27',
// [0] NumMedia: '1',
// [0] ToCity: 'Vancouver',
// [0] FromZip: '',
// [0] SmsSid: 'MM14fa2851ba26e0dc2b62073f8e7cdf27',
// [0] FromState: 'BC',
// [0] SmsStatus: 'received',
// [0] FromCity: 'VANCOUVER',
// [0] Body: '',
// [0] FromCountry: 'CA',
// [0] To: '+16043301606',
// [0] MessagingServiceSid: 'MG6e259e2add04ffa0d0aa355038670ee1',
// [0] ToZip: '',
// [0] NumSegments: '1',
// [0] MessageSid: 'MM14fa2851ba26e0dc2b62073f8e7cdf27',
// [0] AccountSid: 'AC6c09d337d6b9c68ab6488c2052bd457c',
// [0] From: '+16049992002',
// [0] MediaUrl0: 'https://api.twilio.com/2010-04-01/Accounts/AC6c09d337d6b9c68ab6488c2052bd457c/Messages/MM14fa2851ba26e0dc2b62073f8e7cdf27/Media/MEf129dd37979852f395eb29ffb126e19e',
// [0] ApiVersion: '2010-04-01'
// [0] }
// [0] MediaContentType0: 'image/jpeg',
// MediaContentType0: 'video/3gpp',
const generateMediaArray = (body) => { const generateMediaArray = (body) => {
const { NumMedia } = body; const { NumMedia } = body;
if (parseInt(NumMedia) > 0) { if (parseInt(NumMedia) > 0) {
//stuff
const ret = []; const ret = [];
for (var i = 0; i < parseInt(NumMedia); i++) { for (let i = 0; i < parseInt(NumMedia); i++) {
ret.push(body[`MediaUrl${i}`]); ret.push(body[`MediaUrl${i}`]);
} }
return ret; return ret;

View File

@@ -9,11 +9,14 @@ const queries = require("../graphql-client/queries");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY); const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY);
const { admin } = require("../firebase/firebase-handler"); const { admin } = require("../firebase/firebase-handler");
const gqlClient = require("../graphql-client/graphql-client").client; const gqlClient = require("../graphql-client/graphql-client").client;
exports.send = (req, res) => { exports.send = (req, res) => {
const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body; const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body;
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
logger.log("sms-outbound", "DEBUG", req.user.email, null, { logger.log("sms-outbound", "DEBUG", req.user.email, null, {
messagingServiceSid: messagingServiceSid, messagingServiceSid: messagingServiceSid,
@@ -59,18 +62,36 @@ exports.send = (req, res) => {
conversationid: newMessage.conversationid || "" conversationid: newMessage.conversationid || ""
}; };
admin.messaging().send({ // TODO Verify
topic: `${imexshopid}-messaging`, // const messageData = response.insert_messages.returning[0];
data
// Broadcast new message to conversation room
const broadcastRoom = getBodyshopRoom(r2.insert_messages.returning[0].conversation.bodyshop.id);
const conversationRoom = getBodyshopConversationRoom({
bodyshopId: r2.insert_messages.returning[0].conversation.bodyshop.id,
conversationId: r2.insert_messages.returning[0].conversation.id
}); });
ioRedis.to(broadcastRoom).emit("new-message-summary", {
isoutbound: true,
conversationId: conversationid,
updated_at: r2.insert_messages.returning[0].updated_at,
msid: message.sid,
summary: true
});
ioRedis.to(conversationRoom).emit("new-message-detailed", {
newMessage: r2.insert_messages.returning[0],
conversationId: conversationid,
summary: false
});
res.sendStatus(200); res.sendStatus(200);
}) })
.catch((e2) => { .catch((e2) => {
logger.log("sms-outbound-error", "ERROR", req.user.email, null, { logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
msid: message.sid, msid: message.sid,
conversationid, conversationid,
error: e2 error: e2.message,
stack: e2.stack
}); });
//res.json({ success: false, message: e2 }); //res.json({ success: false, message: e2 });
@@ -80,7 +101,8 @@ exports.send = (req, res) => {
//res.json({ success: false, message: error }); //res.json({ success: false, message: error });
logger.log("sms-outbound-error", "ERROR", req.user.email, null, { logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
conversationid, conversationid,
error: e1 error: e1.message,
stack: e1.stack
}); });
}); });
} else { } else {

View File

@@ -11,6 +11,10 @@ const { admin } = require("../firebase/firebase-handler");
exports.status = (req, res) => { exports.status = (req, res) => {
const { SmsSid, SmsStatus } = req.body; const { SmsSid, SmsStatus } = req.body;
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
client client
.request(queries.UPDATE_MESSAGE_STATUS, { .request(queries.UPDATE_MESSAGE_STATUS, {
msid: SmsSid, msid: SmsSid,
@@ -21,6 +25,17 @@ exports.status = (req, res) => {
msid: SmsSid, msid: SmsSid,
fields: { status: SmsStatus } fields: { status: SmsStatus }
}); });
// TODO Verify
const message = response.update_messages.returning[0];
const conversationRoom = getBodyshopConversationRoom({
bodyshopId: message.conversation.bodyshopid,
conversationId: message.conversationid
});
ioRedis.to(conversationRoom).emit("message-changed", {
message
});
}) })
.catch((error) => { .catch((error) => {
logger.log("sms-status-update-error", "ERROR", "api", null, { logger.log("sms-status-update-error", "ERROR", "api", null, {
@@ -34,18 +49,44 @@ exports.status = (req, res) => {
exports.markConversationRead = async (req, res) => { exports.markConversationRead = async (req, res) => {
const { conversationid, imexshopid } = req.body; const { conversationid, imexshopid } = req.body;
admin.messaging().send({ const {
topic: `${imexshopid}-messaging`, ioRedis,
// notification: { ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
// title: `ImEX Online Message - ${data.phone_num}`, } = req;
// body: message.image_path ? `Image ${message.text}` : message.text,
// imageUrl: "https://thinkimex.com/img/logo512.png", //Server side, mark the conversation as read
// },
data: { //TODO: Convert this to run on server side. Stolen from chat-conversation.container.jsx
type: "messaging-mark-conversation-read", // const [markConversationRead] = useMutation(MARK_MESSAGES_AS_READ_BY_CONVERSATION, {
conversationid: conversationid || "" // variables: { conversationId: selectedConversation },
} // refetchQueries: ["UNREAD_CONVERSATION_COUNT"],
//
// update(cache) {
// cache.modify({
// id: cache.identify({
// __typename: "conversations",
// id: selectedConversation
// }),
// fields: {
// messages_aggregate(cached) {
// return { aggregate: { count: 0 } };
// }
// }
// });
// }
// });
const broadcastRoom = getBodyshopRoom(r2.insert_messages.returning[0].conversation.bodyshop.id);
ioRedis.to(broadcastRoom).emit("conversation-changed", {
//type: "conversation-marked-unread" //TODO: Flush out what this looks like.
// isoutbound: true,
// conversationId: conversationid,
// updated_at: r2.insert_messages.returning[0].updated_at,
// msid: message.sid,
// summary: true
}); });
res.send(200); res.send(200);
}; };

View File

@@ -1,8 +1,12 @@
const applyIOHelpers = ({ app, api, io, logger }) => { const applyIOHelpers = ({ app, api, io, logger }) => {
const getBodyshopRoom = (bodyshopID) => `bodyshop-broadcast-room:${bodyshopID}`; const getBodyshopRoom = (bodyshopID) => `bodyshop-broadcast-room:${bodyshopID}`;
// Messaging - conversation specific room to handle detailed messages when the user has a conversation open.
const getBodyshopConversationRoom = ({bodyshopId, conversationId}) =>
`bodyshop-conversation-room:${bodyshopId}:${conversationId}`;
const ioHelpersAPI = { const ioHelpersAPI = {
getBodyshopRoom getBodyshopRoom,
getBodyshopConversationRoom
}; };
// Helper middleware // Helper middleware

View File

@@ -1,9 +1,17 @@
const { admin } = require("../firebase/firebase-handler"); const { admin } = require("../firebase/firebase-handler");
const { MARK_MESSAGES_AS_READ, GET_CONVERSATIONS, GET_CONVERSATION_DETAILS } = require("../graphql-client/queries");
const { phone } = require("phone");
const { client: gqlClient } = require("../graphql-client/graphql-client");
const queries = require("../graphql-client/queries");
const twilio = require("twilio");
const client = require("../graphql-client/graphql-client").client;
const twilioClient = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY);
const redisSocketEvents = ({ const redisSocketEvents = ({
io, io,
redisHelpers: { setSessionData, clearSessionData }, // Note: Used if we persist user to Redis redisHelpers: { setSessionData, clearSessionData }, // Note: Used if we persist user to Redis
ioHelpers: { getBodyshopRoom }, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom },
logger logger
}) => { }) => {
// Logging helper functions // Logging helper functions
@@ -113,6 +121,7 @@ const redisSocketEvents = ({
socket.on("leave-bodyshop-room", leaveBodyshopRoom); socket.on("leave-bodyshop-room", leaveBodyshopRoom);
socket.on("broadcast-to-bodyshop", broadcastToBodyshopRoom); socket.on("broadcast-to-bodyshop", broadcastToBodyshopRoom);
}; };
// Disconnect Events // Disconnect Events
const registerDisconnectEvents = (socket) => { const registerDisconnectEvents = (socket) => {
const disconnect = () => { const disconnect = () => {
@@ -129,10 +138,37 @@ const redisSocketEvents = ({
socket.on("disconnect", disconnect); socket.on("disconnect", disconnect);
}; };
// Messaging Events
const registerMessagingEvents = (socket) => {
const joinConversationRoom = async ({ bodyshopId, conversationId }) => {
try {
const room = getBodyshopConversationRoom({ bodyshopId, conversationId });
socket.join(room);
} catch (error) {
logger.log("error", "Failed to join conversation", error);
socket.emit("error", { message: "Failed to join conversation" });
}
};
const leaveConversationRoom = ({ bodyshopId, conversationId }) => {
try {
const room = getBodyshopConversationRoom({ bodyshopId, conversationId });
socket.leave(room);
// Optionally notify the client
//socket.emit("conversation-left", { conversationId });
} catch (error) {
socket.emit("error", { message: "Failed to leave conversation" });
}
};
socket.on("join-bodyshop-conversation", joinConversationRoom);
socket.on("leave-bodyshop-conversation", leaveConversationRoom);
};
// Call Handlers // Call Handlers
registerRoomAndBroadcastEvents(socket); registerRoomAndBroadcastEvents(socket);
registerUpdateEvents(socket); registerUpdateEvents(socket);
registerMessagingEvents(socket);
registerDisconnectEvents(socket); registerDisconnectEvents(socket);
}; };