Merged in feature/IO-3000-messaging-sockets-migrationv2 (pull request #1948)

Feature/IO-3000 messaging sockets migrationv2 - Enhanced Socket Migrations

Approved-by: Patrick Fic
This commit is contained in:
Dave Richer
2024-11-26 18:48:17 +00:00
committed by Patrick Fic
42 changed files with 2833 additions and 2951 deletions

View File

@@ -85,6 +85,7 @@
"web-vitals": "^3.5.2"
},
"devDependencies": {
"@ant-design/icons": "^5.5.1",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.24.7",
"@dotenvx/dotenvx": "^1.14.1",

View File

@@ -132,6 +132,7 @@
"@rollup/rollup-linux-x64-gnu": "4.6.1"
},
"devDependencies": {
"@ant-design/icons": "^5.5.1",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.24.7",
"@dotenvx/dotenvx": "^1.14.1",

View File

@@ -1,89 +1,30 @@
import { useApolloClient } from "@apollo/client";
import { getToken, onMessage } from "@firebase/messaging";
import { Button, notification, Space } from "antd";
import axios from "axios";
import React, { useEffect } from "react";
import React, { useContext, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { messaging, requestForToken } from "../../firebase/firebase.utils";
import FcmHandler from "../../utils/fcm-handler";
import ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss";
import SocketContext from "../../contexts/SocketIO/socketContext";
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
export function ChatAffixContainer({ bodyshop, chatVisible }) {
const { t } = useTranslation();
const client = useApolloClient();
const { socket } = useContext(SocketContext);
useEffect(() => {
if (!bodyshop || !bodyshop.messagingservicesid) return;
async function SubscribeToTopic() {
try {
const r = await axios.post("/notifications/subscribe", {
fcm_tokens: await getToken(messaging, {
vapidKey: import.meta.env.VITE_APP_FIREBASE_PUBLIC_VAPID_KEY
}),
type: "messaging",
imexshopid: bodyshop.imexshopid
});
console.log("FCM Topic Subscription", r.data);
} catch (error) {
console.log("Error attempting to subscribe to messaging topic: ", error);
notification.open({
key: "fcm",
type: "warning",
message: t("general.errors.fcm"),
btn: (
<Space>
<Button
onClick={async () => {
await requestForToken();
SubscribeToTopic();
}}
>
{t("general.actions.tryagain")}
</Button>
<Button
onClick={() => {
const win = window.open(
"https://help.imex.online/en/article/enabling-notifications-o978xi/",
"_blank"
);
win.focus();
}}
>
{t("general.labels.help")}
</Button>
</Space>
)
});
}
//Register WS handlers
if (socket && socket.connected) {
registerMessagingHandlers({ socket, client });
}
SubscribeToTopic();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bodyshop]);
useEffect(() => {
function handleMessage(payload) {
FcmHandler({
client,
payload: (payload && payload.data && payload.data.data) || payload.data
});
}
let stopMessageListener, channel;
try {
stopMessageListener = onMessage(messaging, handleMessage);
channel = new BroadcastChannel("imex-sw-messages");
channel.addEventListener("message", handleMessage);
} catch (error) {
console.log("Unable to set event listeners.");
}
return () => {
stopMessageListener && stopMessageListener();
channel && channel.removeEventListener("message", handleMessage);
if (socket && socket.connected) {
unregisterMessagingHandlers({ socket });
}
};
}, [client]);
}, [bodyshop, socket, t, client]);
if (!bodyshop || !bodyshop.messagingservicesid) return <></>;

View File

@@ -0,0 +1,429 @@
import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import { gql } from "@apollo/client";
const logLocal = (message, ...args) => {
if (import.meta.env.PROD) {
return;
}
console.log(`==================== ${message} ====================`);
console.dir({ ...args });
};
export const registerMessagingHandlers = ({ socket, client }) => {
if (!(socket && client)) return;
const handleNewMessageSummary = async (message) => {
const { conversationId, newConversation, existingConversation, isoutbound } = message;
logLocal("handleNewMessageSummary - Start", { message, isNew: !existingConversation });
const queryVariables = { offset: 0 };
// Utility function to enrich conversation data
const enrichConversation = (conversation, isOutbound) => ({
...conversation,
updated_at: conversation.updated_at || new Date().toISOString(),
unreadcnt: conversation.unreadcnt || 0,
archived: conversation.archived || false,
label: conversation.label || null,
job_conversations: conversation.job_conversations || [],
messages_aggregate: conversation.messages_aggregate || {
__typename: "messages_aggregate",
aggregate: {
__typename: "messages_aggregate_fields",
count: isOutbound ? 0 : 1
}
},
__typename: "conversations"
});
// Handle new conversation
if (!existingConversation && newConversation?.phone_num) {
logLocal("handleNewMessageSummary - New Conversation", newConversation);
try {
const queryResults = client.cache.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: queryVariables
});
const existingConversations = queryResults?.conversations || [];
const enrichedConversation = enrichConversation(newConversation, isoutbound);
// Avoid adding duplicate conversations
if (!existingConversations.some((conv) => conv.id === enrichedConversation.id)) {
client.cache.modify({
id: "ROOT_QUERY",
fields: {
conversations(existingConversations = []) {
return [enrichedConversation, ...existingConversations];
}
}
});
}
} catch (error) {
console.error("Error updating cache for new conversation:", error);
}
return;
}
// Handle existing conversation
if (existingConversation) {
let conversationDetails;
// Attempt to read existing conversation details from cache
try {
conversationDetails = client.cache.readFragment({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fragment: gql`
fragment ExistingConversation on conversations {
id
phone_num
updated_at
archived
label
unreadcnt
job_conversations {
jobid
conversationid
}
messages_aggregate {
aggregate {
count
}
}
__typename
}
`
});
} catch (error) {
logLocal("handleNewMessageSummary - Cache miss for conversation, fetching from server", { conversationId });
}
// Fetch conversation details from server if not in cache
if (!conversationDetails) {
try {
const { data } = await client.query({
query: GET_CONVERSATION_DETAILS,
variables: { conversationId },
fetchPolicy: "network-only"
});
conversationDetails = data?.conversations_by_pk;
} catch (error) {
console.error("Failed to fetch conversation details from server:", error);
return;
}
}
// Validate that conversation details were retrieved
if (!conversationDetails) {
console.error("Unable to retrieve conversation details. Skipping cache update.");
return;
}
try {
// Check if the conversation is already in the cache
const queryResults = client.cache.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: queryVariables
});
const isAlreadyInCache = queryResults?.conversations.some((conv) => conv.id === conversationId);
if (!isAlreadyInCache) {
const enrichedConversation = enrichConversation(conversationDetails, isoutbound);
client.cache.modify({
id: "ROOT_QUERY",
fields: {
conversations(existingConversations = []) {
return [enrichedConversation, ...existingConversations];
}
}
});
}
// Update fields for the existing conversation in the cache
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
updated_at: () => new Date().toISOString(),
archived: () => false,
messages_aggregate(cached = { aggregate: { count: 0 } }) {
const currentCount = cached.aggregate?.count || 0;
if (!isoutbound) {
return {
__typename: "messages_aggregate",
aggregate: {
__typename: "messages_aggregate_fields",
count: currentCount + 1
}
};
}
return cached;
}
}
});
} catch (error) {
console.error("Error updating cache for existing conversation:", error);
}
}
};
const handleNewMessageDetailed = (message) => {
const { conversationId, newMessage } = message;
logLocal("handleNewMessageDetailed - Start", message);
try {
// Check if the conversation exists in the cache
const queryResults = client.cache.readQuery({
query: GET_CONVERSATION_DETAILS,
variables: { conversationId }
});
if (!queryResults?.conversations_by_pk) {
console.warn("Conversation not found in cache:", { conversationId });
return;
}
// Append the new message to the conversation's message list using cache.modify
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
messages(existingMessages = []) {
return [...existingMessages, newMessage];
}
}
});
logLocal("handleNewMessageDetailed - Message appended successfully", {
conversationId,
newMessage
});
} catch (error) {
console.error("Error updating conversation messages in cache:", error);
}
};
const handleMessageChanged = (message) => {
if (!message) {
logLocal("handleMessageChanged - No message provided", message);
return;
}
logLocal("handleMessageChanged - Start", message);
try {
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: message.conversationid }),
fields: {
messages(existingMessages = [], { readField }) {
return existingMessages.map((messageRef) => {
// Check if this is the message to update
if (readField("id", messageRef) === message.id) {
const currentStatus = readField("status", messageRef);
// Handle known types of message changes
switch (message.type) {
case "status-changed":
// Prevent overwriting if the current status is already "delivered"
if (currentStatus === "delivered") {
logLocal("handleMessageChanged - Status already delivered, skipping update", {
messageId: message.id
});
return messageRef;
}
// Update the status field
return {
...messageRef,
status: message.status
};
case "text-updated":
// Handle changes to the message text
return {
...messageRef,
text: message.text
};
// Add cases for other known message types as needed
default:
// Log a warning for unhandled message types
logLocal("handleMessageChanged - Unhandled message type", { type: message.type });
return messageRef;
}
}
return messageRef; // Keep other messages unchanged
});
}
}
});
logLocal("handleMessageChanged - Message updated successfully", {
messageId: message.id,
type: message.type
});
} catch (error) {
console.error("handleMessageChanged - Error modifying cache:", error);
}
};
const handleConversationChanged = async (data) => {
if (!data) {
logLocal("handleConversationChanged - No data provided", data);
return;
}
const { conversationId, type, job_conversations, messageIds, ...fields } = data;
logLocal("handleConversationChanged - Start", data);
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
}
});
logLocal("handleConversationChanged - Conversation list updated successfully", newConversation);
} catch (error) {
console.error("Error updating conversation list in the cache:", error);
}
};
// Handle specific types
try {
switch (type) {
case "conversation-marked-read":
if (conversationId && messageIds?.length > 0) {
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
messages(existingMessages = [], { readField }) {
return existingMessages.map((message) => {
if (messageIds.includes(readField("id", message))) {
return { ...message, read: true };
}
return message;
});
},
messages_aggregate: () => ({
__typename: "messages_aggregate",
aggregate: { __typename: "messages_aggregate_fields", count: 0 }
})
}
});
}
break;
case "conversation-created":
updateConversationList({ ...fields, job_conversations, updated_at: updatedAt });
break;
case "conversation-unarchived":
case "conversation-archived":
// Would like to someday figure out how to get this working without refetch queries,
// But I have but a solid 4 hours into it, and there are just too many weird occurrences
try {
const listQueryVariables = { offset: 0 };
const detailsQueryVariables = { conversationId };
// Check if conversation details exist in the cache
const detailsExist = !!client.cache.readQuery({
query: GET_CONVERSATION_DETAILS,
variables: detailsQueryVariables
});
// Refetch conversation list
await client.refetchQueries({
include: [CONVERSATION_LIST_QUERY, ...(detailsExist ? [GET_CONVERSATION_DETAILS] : [])],
variables: [
{ query: CONVERSATION_LIST_QUERY, variables: listQueryVariables },
...(detailsExist
? [
{
query: GET_CONVERSATION_DETAILS,
variables: detailsQueryVariables
}
]
: [])
]
});
logLocal("handleConversationChanged - Refetched queries after state change", {
conversationId,
type
});
} catch (error) {
console.error("Error refetching queries after conversation state change:", error);
}
break;
case "tag-added":
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
job_conversations: (existing = []) => [...existing, ...job_conversations]
}
});
break;
case "tag-removed":
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
job_conversations: (existing = [], { readField }) =>
existing.filter((jobRef) => readField("jobid", jobRef) !== fields.jobId)
}
});
break;
default:
logLocal("handleConversationChanged - Unhandled type", { type });
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
...Object.fromEntries(
Object.entries(fields).map(([key, value]) => [key, (cached) => (value !== undefined ? value : cached)])
)
}
});
}
} catch (error) {
console.error("Error handling conversation changes:", { type, error });
}
};
socket.on("new-message-summary", handleNewMessageSummary);
socket.on("new-message-detailed", handleNewMessageDetailed);
socket.on("message-changed", handleMessageChanged);
socket.on("conversation-changed", handleConversationChanged);
};
export const unregisterMessagingHandlers = ({ socket }) => {
if (!socket) return;
socket.off("new-message");
socket.off("new-message-summary");
socket.off("new-message-detailed");
socket.off("message-changed");
socket.off("conversation-changed");
};

View File

@@ -1,21 +1,41 @@
import { useMutation } from "@apollo/client";
import { Button } from "antd";
import React, { useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
export default function ChatArchiveButton({ conversation }) {
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
export function ChatArchiveButton({ conversation, bodyshop }) {
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const [updateConversation] = useMutation(TOGGLE_CONVERSATION_ARCHIVE);
const { socket } = useContext(SocketContext);
const handleToggleArchive = async () => {
setLoading(true);
await updateConversation({
variables: { id: conversation.id, archived: !conversation.archived },
refetchQueries: ["CONVERSATION_LIST_QUERY"]
const updatedConversation = await updateConversation({
variables: { id: conversation.id, archived: !conversation.archived }
});
if (socket) {
socket.emit("conversation-modified", {
type: "conversation-archived",
conversationId: conversation.id,
bodyshopId: bodyshop.id,
archived: updatedConversation.data.update_conversations_by_pk.archived
});
}
setLoading(false);
};
@@ -25,3 +45,5 @@ export default function ChatArchiveButton({ conversation }) {
</Button>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatArchiveButton);

View File

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

View File

@@ -1,7 +1,7 @@
.chat-list-container {
overflow: hidden;
height: 100%;
height: 100%; /* Ensure it takes up the full available height */
border: 1px solid gainsboro;
overflow: auto; /* Allow scrolling for the Virtuoso component */
}
.chat-list-item {
@@ -14,3 +14,24 @@
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,24 @@
import { useMutation } from "@apollo/client";
import { Tag } from "antd";
import React from "react";
import React, { useContext } from "react";
import { Link } from "react-router-dom";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
export default function ChatConversationTitleTags({ jobConversations }) {
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
const { socket } = useContext(SocketContext);
const handleRemoveTag = (jobId) => {
const convId = jobConversations[0].conversationid;
@@ -27,6 +38,16 @@ export default function ChatConversationTitleTags({ jobConversations }) {
}
});
}
}).then(() => {
if (socket) {
// Emit the `conversation-modified` event
socket.emit("conversation-modified", {
bodyshopId: bodyshop.id,
conversationId: convId,
type: "tag-removed",
jobId: jobId
});
}
});
logImEXEvent("messaging_remove_job_tag", {
conversationId: convId,
@@ -54,3 +75,5 @@ export default function ChatConversationTitleTags({ jobConversations }) {
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationTitleTags);

View File

@@ -6,8 +6,14 @@ import ChatConversationTitleTags from "../chat-conversation-title-tags/chat-conv
import ChatLabelComponent from "../chat-label/chat-label.component";
import ChatPrintButton from "../chat-print-button/chat-print-button.component";
import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container";
import { createStructuredSelector } from "reselect";
import { connect } from "react-redux";
export default function ChatConversationTitle({ conversation }) {
const mapStateToProps = createStructuredSelector({});
const mapDispatchToProps = () => ({});
export function ChatConversationTitle({ conversation }) {
return (
<Space wrap>
<PhoneNumberFormatter>{conversation && conversation.phone_num}</PhoneNumberFormatter>
@@ -19,3 +25,5 @@ export default function ChatConversationTitle({ conversation }) {
</Space>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationTitle);

View File

@@ -5,10 +5,26 @@ import ChatMessageListComponent from "../chat-messages-list/chat-message-list.co
import ChatSendMessage from "../chat-send-message/chat-send-message.component";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component.jsx";
import "./chat-conversation.styles.scss";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
export default function ChatConversationComponent({ subState, conversation, messages, handleMarkConversationAsRead }) {
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
export function ChatConversationComponent({
subState,
conversation,
messages,
handleMarkConversationAsRead,
bodyshop
}) {
const [loading, error] = subState;
if (conversation?.archived) return null;
if (loading) return <LoadingSkeleton />;
if (error) return <AlertComponent message={error.message} type="error" />;
@@ -18,9 +34,11 @@ export default function ChatConversationComponent({ subState, conversation, mess
onMouseDown={handleMarkConversationAsRead}
onKeyDown={handleMarkConversationAsRead}
>
<ChatConversationTitle conversation={conversation} />
<ChatConversationTitle conversation={conversation} bodyshop={bodyshop} />
<ChatMessageListComponent messages={messages} />
<ChatSendMessage conversation={conversation} />
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationComponent);

View File

@@ -1,22 +1,24 @@
import { useMutation, useQuery, useSubscription } from "@apollo/client";
import React, { useState } from "react";
import { useApolloClient, useQuery } from "@apollo/client";
import axios from "axios";
import React, { useCallback, useContext, useEffect, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import { MARK_MESSAGES_AS_READ_BY_CONVERSATION } from "../../graphql/messages.queries";
import SocketContext from "../../contexts/SocketIO/socketContext";
import { GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
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 ChatConversationComponent from "./chat-conversation.component";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
bodyshop: selectBodyshop
});
export default connect(mapStateToProps, null)(ChatConversationContainer);
export function ChatConversationContainer({ bodyshop, selectedConversation }) {
const client = useApolloClient();
const { socket } = useContext(SocketContext);
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
const {
loading: convoLoading,
error: convoError,
@@ -27,55 +29,123 @@ export function ChatConversationContainer({ bodyshop, selectedConversation }) {
nextFetchPolicy: "network-only"
});
const { loading, error, data } = useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, {
variables: { conversationId: selectedConversation }
});
const updateCacheWithReadMessages = useCallback(
(conversationId, messageIds) => {
if (!conversationId || !messageIds || messageIds.length === 0) return;
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
// Mark individual messages as read
messageIds.forEach((messageId) => {
client.cache.modify({
id: client.cache.identify({ __typename: "messages", id: messageId }),
fields: {
read() {
return true; // Mark message as read
}
}
});
});
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
}),
// Update aggregate unread count for the conversation
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
messages_aggregate(cached) {
return { aggregate: { count: 0 } };
messages_aggregate(existingAggregate) {
return {
...existingAggregate,
aggregate: {
...existingAggregate.aggregate,
count: 0 // No unread messages remaining
}
};
}
}
});
}
});
},
[client.cache]
);
const unreadCount =
data &&
data.messages &&
data.messages.reduce((acc, val) => {
return !val.read && !val.isoutbound ? acc + 1 : acc;
}, 0);
// Handle WebSocket events
useEffect(() => {
if (!socket || !socket.connected) return;
const handleMarkConversationAsRead = async () => {
if (unreadCount > 0 && !!selectedConversation && !markingAsReadInProgress) {
setMarkingAsReadInProgress(true);
await markConversationRead({});
await axios.post("/sms/markConversationRead", {
conversationid: selectedConversation,
imexshopid: bodyshop.imexshopid
const handleConversationChange = (data) => {
if (data.type === "conversation-marked-read") {
const { conversationId, messageIds } = data;
console.log("Conversation change received:", data);
updateCacheWithReadMessages(conversationId, messageIds);
}
};
socket.on("conversation-changed", handleConversationChange);
return () => {
socket.off("conversation-changed", handleConversationChange);
};
}, [socket, client, updateCacheWithReadMessages]);
// Handle joining/leaving conversation
useEffect(() => {
if (!socket || !socket.connected) return;
socket.emit("join-bodyshop-conversation", {
bodyshopId: bodyshop.id,
conversationId: selectedConversation
});
return () => {
socket.emit("leave-bodyshop-conversation", {
bodyshopId: bodyshop.id,
conversationId: selectedConversation
});
setMarkingAsReadInProgress(false);
};
}, [selectedConversation, bodyshop, socket]);
// Handle marking conversation as read
const handleMarkConversationAsRead = async () => {
if (!convoData || !selectedConversation || markingAsReadInProgress) return;
const conversation = convoData.conversations_by_pk;
if (!conversation) {
console.warn(`No data found for conversation ID: ${selectedConversation}`);
return;
}
const unreadMessageIds = conversation.messages
?.filter((message) => !message.read && !message.isoutbound)
.map((message) => message.id);
if (unreadMessageIds?.length > 0) {
setMarkingAsReadInProgress(true);
try {
const payload = {
conversation,
imexshopid: bodyshop?.imexshopid,
bodyshopid: bodyshop?.id
};
console.log("Marking conversation as read:", payload);
await axios.post("/sms/markConversationRead", payload);
// Update local cache
updateCacheWithReadMessages(selectedConversation, unreadMessageIds);
} catch (error) {
console.error("Error marking conversation as read:", error.response?.data || error.message);
} finally {
setMarkingAsReadInProgress(false);
}
}
};
return (
<ChatConversationComponent
subState={[loading || convoLoading, error || convoError]}
subState={[convoLoading, convoError]}
conversation={convoData ? convoData.conversations_by_pk : {}}
messages={data ? data.messages : []}
messages={convoData ? convoData.conversations_by_pk.messages : []}
handleMarkConversationAsRead={handleMarkConversationAsRead}
/>
);
}
export default connect(mapStateToProps, null)(ChatConversationContainer);

View File

@@ -1,14 +1,25 @@
import { PlusOutlined } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { Input, notification, Spin, Tag, Tooltip } from "antd";
import React, { useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
export default function ChatLabel({ conversation }) {
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({});
export function ChatLabel({ conversation, bodyshop }) {
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(conversation.label);
const { socket } = useContext(SocketContext);
const { t } = useTranslation();
const [updateLabel] = useMutation(UPDATE_CONVERSATION_LABEL);
@@ -26,6 +37,14 @@ export default function ChatLabel({ conversation }) {
})
});
} else {
if (socket) {
socket.emit("conversation-modified", {
type: "label-updated",
conversationId: conversation.id,
bodyshopId: bodyshop.id,
label: value
});
}
setEditing(false);
}
} catch (error) {
@@ -57,3 +76,5 @@ export default function ChatLabel({ conversation }) {
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatLabel);

View File

@@ -1,106 +1,55 @@
import Icon from "@ant-design/icons";
import { Tooltip } from "antd";
import i18n from "i18next";
import dayjs from "../../utils/day";
import React, { useEffect, useRef } from "react";
import { MdDone, MdDoneAll } from "react-icons/md";
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from "react-virtualized";
import { DateTimeFormatter } from "../../utils/DateFormatter";
import { Virtuoso } from "react-virtuoso";
import { renderMessage } from "./renderMessage";
import "./chat-message-list.styles.scss";
const SCROLL_DELAY_MS = 50;
const INITIAL_SCROLL_DELAY_MS = 100;
export default function ChatMessageListComponent({ messages }) {
const virtualizedListRef = useRef(null);
const virtuosoRef = useRef(null);
const _cache = new CellMeasurerCache({
fixedWidth: true,
// minHeight: 50,
defaultHeight: 100
});
// Scroll to the bottom after a short delay when the component mounts
useEffect(() => {
const timer = setTimeout(() => {
if (virtuosoRef?.current?.scrollToIndex && messages?.length) {
virtuosoRef.current.scrollToIndex({
index: messages.length - 1,
behavior: "auto" // Instantly scroll to the bottom
});
}
}, INITIAL_SCROLL_DELAY_MS);
const scrollToBottom = (renderedrows) => {
//console.log("Scrolling to", messages.length);
// !!virtualizedListRef.current &&
// virtualizedListRef.current.scrollToRow(messages.length);
// Outstanding isue on virtualization: https://github.com/bvaughn/react-virtualized/issues/1179
//Scrolling does not work on this version of React.
};
// Cleanup the timeout on unmount
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // ESLint is disabled for this line because we only want this to load once (valid exception)
useEffect(scrollToBottom, [messages]);
// Scroll to the bottom after the new messages are rendered
useEffect(() => {
if (virtuosoRef?.current?.scrollToIndex && messages?.length) {
const timeout = setTimeout(() => {
virtuosoRef.current.scrollToIndex({
index: messages.length - 1,
align: "end", // Ensure the last message is fully visible
behavior: "smooth" // Smooth scrolling
});
}, SCROLL_DELAY_MS); // Slight delay to ensure layout recalculates
const _rowRenderer = ({ index, key, parent, style }) => {
return (
<CellMeasurer cache={_cache} key={key} rowIndex={index} parent={parent}>
{({ measure, registerChild }) => (
<div
ref={registerChild}
onLoad={measure}
style={style}
className={`${messages[index].isoutbound ? "mine messages" : "yours messages"}`}
>
<div className="message msgmargin">
{MessageRender(messages[index])}
{StatusRender(messages[index].status)}
</div>
{messages[index].isoutbound && (
<div style={{ fontSize: 10 }}>
{i18n.t("messaging.labels.sentby", {
by: messages[index].userid,
time: dayjs(messages[index].created_at).format("MM/DD/YYYY @ hh:mm a")
})}
</div>
)}
</div>
)}
</CellMeasurer>
);
};
// Cleanup timeout on dependency changes
return () => clearTimeout(timeout);
}
}, [messages]); // Triggered when new messages are added
return (
<div className="chat">
<AutoSizer>
{({ height, width }) => (
<List
ref={virtualizedListRef}
width={width}
height={height}
rowHeight={_cache.rowHeight}
rowRenderer={_rowRenderer}
rowCount={messages.length}
overscanRowCount={10}
estimatedRowSize={150}
scrollToIndex={messages.length}
/>
)}
</AutoSizer>
<Virtuoso
ref={virtuosoRef}
data={messages}
itemContent={(index) => renderMessage(messages, index)} // Pass `messages` to renderMessage
followOutput="smooth" // Ensure smooth scrolling when new data is appended
style={{ height: "100%", width: "100%" }}
/>
</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 {
//position: absolute;
// bottom: 0rem;
color: whitesmoke;
border: #000000;
position: absolute;
margin: 0 0.1rem;
bottom: 0.1rem;
right: 0.3rem;
z-index: 5;
}
.chat {
flex: 1;
//width: 300px;
//border: solid 1px #eee;
display: flex;
flex-direction: column;
margin: 0.8rem 0rem;
overflow: hidden; // Ensure the content scrolls correctly
}
.messages {
//margin-top: 30px;
display: flex;
flex-direction: column;
padding: 0.5rem; // Add padding to avoid edge clipping
}
.message {
border-radius: 20px;
padding: 0.25rem 0.8rem;
//margin-top: 5px;
// margin-bottom: 5px;
//display: inline-block;
.message-img {
max-width: 10rem;
@@ -56,7 +49,7 @@
position: relative;
}
.yours .message.last:before {
.yours .message:last-child:before {
content: "";
position: absolute;
z-index: 0;
@@ -68,7 +61,7 @@
border-bottom-right-radius: 15px;
}
.yours .message.last:after {
.yours .message:last-child:after {
content: "";
position: absolute;
z-index: 1;
@@ -88,12 +81,11 @@
color: white;
margin-left: 25%;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
background-attachment: fixed;
position: relative;
padding-bottom: 0.6rem;
}
.mine .message.last:before {
.mine .message:last-child:before {
content: "";
position: absolute;
z-index: 0;
@@ -102,11 +94,10 @@
height: 20px;
width: 20px;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
background-attachment: fixed;
border-bottom-left-radius: 15px;
}
.mine .message.last:after {
.mine .message:last-child:after {
content: "";
position: absolute;
z-index: 1;

View File

@@ -0,0 +1,42 @@
import Icon from "@ant-design/icons";
import { Tooltip } from "antd";
import i18n from "i18next";
import dayjs from "../../utils/day";
import { MdDone, MdDoneAll } from "react-icons/md";
import { DateTimeFormatter } from "../../utils/DateFormatter";
export const renderMessage = (messages, index) => {
const message = messages[index];
return (
<div key={index} className={`${message.isoutbound ? "mine messages" : "yours messages"}`}>
<div className="message msgmargin">
<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" rel="noopener noreferrer">
<img alt="Received" className="message-img" src={i} />
</a>
</div>
))}
<div>{message.text}</div>
</div>
</Tooltip>
{message.status && (message.status === "sent" || message.status === "delivered") && (
<div className="message-status">
<Icon component={message.status === "sent" ? MdDone : MdDoneAll} className="message-icon" />
</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>
);
};

View File

@@ -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();
};

View File

@@ -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 <PhoneNumberFormatter>{phone}</PhoneNumberFormatter>;
@@ -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") });
}

View File

@@ -1,11 +1,11 @@
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
import { useLazyQuery, useQuery } from "@apollo/client";
import { useApolloClient, useLazyQuery } from "@apollo/client";
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
import React, { useCallback, useEffect, useState } from "react";
import React, { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { CONVERSATION_LIST_QUERY, UNREAD_CONVERSATION_COUNT } from "../../graphql/conversations.queries";
import { CONVERSATION_LIST_QUERY } from "../../graphql/conversations.queries";
import { toggleChatVisible } from "../../redux/messaging/messaging.actions";
import { selectChatVisible, selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import ChatConversationListComponent from "../chat-conversation-list/chat-conversation-list.component";
@@ -13,61 +13,88 @@ import ChatConversationContainer from "../chat-conversation/chat-conversation.co
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import "./chat-popup.styles.scss";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
chatVisible: selectChatVisible
});
const mapDispatchToProps = (dispatch) => ({
toggleChatVisible: () => dispatch(toggleChatVisible())
});
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
const { t } = useTranslation();
const [pollInterval, setpollInterval] = useState(0);
const [pollInterval, setPollInterval] = useState(0);
const { socket } = useContext(SocketContext);
const client = useApolloClient(); // Apollo Client instance for cache operations
const { data: unreadData } = useQuery(UNREAD_CONVERSATION_COUNT, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
...(pollInterval > 0 ? { pollInterval } : {})
});
const [getConversations, { loading, data, refetch, fetchMore }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
// Lazy query for conversations
const [getConversations, { loading, data, refetch }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
skip: !chatVisible,
...(pollInterval > 0 ? { pollInterval } : {})
});
const fcmToken = sessionStorage.getItem("fcmtoken");
// Socket connection status
useEffect(() => {
if (fcmToken) {
setpollInterval(0);
} else {
setpollInterval(90000);
}
}, [fcmToken]);
const handleSocketStatus = () => {
if (socket?.connected) {
setPollInterval(15 * 60 * 1000); // 15 minutes
} else {
setPollInterval(60 * 1000); // 60 seconds
}
};
handleSocketStatus();
if (socket) {
socket.on("connect", handleSocketStatus);
socket.on("disconnect", handleSocketStatus);
}
return () => {
if (socket) {
socket.off("connect", handleSocketStatus);
socket.off("disconnect", handleSocketStatus);
}
};
}, [socket]);
// Fetch conversations when chat becomes visible
useEffect(() => {
if (chatVisible)
getConversations({
variables: {
offset: 0
}
}).catch((err) => {
console.error(`Error fetching conversations: ${(err, err.message || "")}`);
});
}, [chatVisible, getConversations]);
const loadMoreConversations = useCallback(() => {
if (data)
fetchMore({
variables: {
offset: data.conversations.length
}
// Get unread count from the cache
const unreadCount = (() => {
try {
const cachedData = client.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: { offset: 0 }
});
}, [data, fetchMore]);
const unreadCount = unreadData?.messages_aggregate.aggregate.count || 0;
if (!cachedData?.conversations) return 0;
// Aggregate unread message count
return cachedData.conversations.reduce((total, conversation) => {
const unread = conversation.messages_aggregate?.aggregate?.count || 0;
return total + unread;
}, 0);
} catch (error) {
console.warn("Unread count not found in cache:", error);
return 0; // Fallback if not in cache
}
})();
return (
<Badge count={unreadCount}>
@@ -93,10 +120,7 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
{loading ? (
<LoadingSpinner />
) : (
<ChatConversationListComponent
conversationList={data ? data.conversations : []}
loadMoreConversations={loadMoreConversations}
/>
<ChatConversationListComponent conversationList={data ? data.conversations : []} />
)}
</Col>
<Col span={16}>{selectedConversation ? <ChatConversationContainer /> : null}</Col>

View File

@@ -25,6 +25,7 @@ const mapDispatchToProps = (dispatch) => ({
function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) {
const inputArea = useRef(null);
const [selectedMedia, setSelectedMedia] = useState([]);
useEffect(() => {
inputArea.current.focus();
}, [isSending, setMessage]);
@@ -37,14 +38,15 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
logImEXEvent("messaging_send_message");
if (selectedImages.length < 11) {
sendMessage({
const newMessage = {
to: conversation.phone_num,
body: message || "",
messagingServiceSid: bodyshop.messagingservicesid,
conversationid: conversation.id,
selectedMedia: selectedImages,
imexshopid: bodyshop.imexshopid
});
};
sendMessage(newMessage);
setSelectedMedia(
selectedMedia.map((i) => {
return { ...i, isSelected: false };

View File

@@ -2,16 +2,27 @@ import { PlusOutlined } from "@ant-design/icons";
import { useLazyQuery, useMutation } from "@apollo/client";
import { Tag } from "antd";
import _ from "lodash";
import React, { useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
import ChatTagRo from "./chat-tag-ro.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
export default function ChatTagRoContainer({ conversation }) {
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
export function ChatTagRoContainer({ conversation, bodyshop }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { socket } = useContext(SocketContext);
const [loadRo, { loading, data }] = useLazyQuery(SEARCH_FOR_JOBS);
@@ -32,7 +43,31 @@ export default function ChatTagRoContainer({ conversation }) {
const handleInsertTag = (value, option) => {
logImEXEvent("messaging_add_job_tag");
insertTag({ variables: { jobId: option.key } });
insertTag({
variables: { jobId: option.key }
}).then(() => {
if (socket) {
// Find the job details from the search data
const selectedJob = data?.search_jobs.find((job) => job.id === option.key);
if (!selectedJob) return;
const newJobConversation = {
__typename: "job_conversations",
jobid: selectedJob.id,
conversationid: conversation.id,
job: {
__typename: "jobs",
...selectedJob
}
};
socket.emit("conversation-modified", {
conversationId: conversation.id,
bodyshopId: bodyshop.id,
type: "tag-added",
job_conversations: [newJobConversation]
});
}
});
setOpen(false);
};
@@ -60,3 +95,5 @@ export default function ChatTagRoContainer({ conversation }) {
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatTagRoContainer);

View File

@@ -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 = (
<Space direction="vertical" wrap>
<Input
@@ -190,7 +193,8 @@ export function ScheduleEventComponent({
if (p && p.isValid()) {
openChatByPhone({
phone_num: p.formatInternational(),
jobid: event.job.id
jobid: event.job.id,
socket
});
setMessage(
t("appointments.labels.reminder", {

View File

@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
@@ -26,21 +25,16 @@ const mapStateToProps = createStructuredSelector({
const mapDispatchToProps = (dispatch) => ({
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 &&

View File

@@ -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 {

View File

@@ -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", {

View File

@@ -1,18 +1,33 @@
import { connect } from "react-redux";
import { GlobalOutlined } from "@ant-design/icons";
import { GlobalOutlined, WarningOutlined } from "@ant-design/icons";
import { createStructuredSelector } from "reselect";
import React from "react";
import { selectWssStatus } from "../../redux/application/application.selectors";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
wssStatus: selectWssStatus
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(WssStatusDisplay);
const mapDispatchToProps = (dispatch) => ({});
export function WssStatusDisplay({ wssStatus }) {
console.log("🚀 ~ WssStatusDisplay ~ wssStatus:", wssStatus);
return <GlobalOutlined style={{ color: wssStatus === "connected" ? "green" : "red", marginRight: ".5rem" }} />;
let icon;
let color;
if (wssStatus === "connected") {
icon = <GlobalOutlined />;
color = "green";
} else if (wssStatus === "error") {
icon = <WarningOutlined />;
color = "red";
} else {
icon = <GlobalOutlined />;
color = "gray"; // Default for other statuses like "disconnected"
}
return <span style={{ color, marginRight: ".5rem" }}>{icon}</span>;
}
export default connect(mapStateToProps, mapDispatchToProps)(WssStatusDisplay);

View File

@@ -9,68 +9,96 @@ const useSocket = (bodyshop) => {
const [clientId, setClientId] = useState(null);
useEffect(() => {
const initializeSocket = async (token) => {
if (!bodyshop || !bodyshop.id) return;
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
const socketInstance = SocketIO(endpoint, {
path: "/wss",
withCredentials: true,
auth: { token },
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000
});
socketRef.current = socketInstance;
// Handle socket events
const handleBodyshopMessage = (message) => {
if (!message || !message.type) return;
switch (message.type) {
case "alert-update":
store.dispatch(addAlerts(message.payload));
break;
default:
break;
}
if (!import.meta.env.DEV) return;
console.log(`Received message for bodyshop ${bodyshop.id}:`, message);
};
const handleConnect = () => {
socketInstance.emit("join-bodyshop-room", bodyshop.id);
setClientId(socketInstance.id);
store.dispatch(setWssStatus("connected"));
};
const handleReconnect = () => {
store.dispatch(setWssStatus("connected"));
};
const handleConnectionError = (err) => {
console.error("Socket connection error:", err);
// Handle token expiration
if (err.message.includes("auth/id-token-expired")) {
console.warn("Token expired, refreshing...");
auth.currentUser?.getIdToken(true).then((newToken) => {
socketInstance.auth = { token: newToken }; // Update socket auth
socketInstance.connect(); // Retry connection
});
} else {
store.dispatch(setWssStatus("error"));
}
};
const handleDisconnect = (reason) => {
console.warn("Socket disconnected:", reason);
store.dispatch(setWssStatus("disconnected"));
// Manually trigger reconnection if necessary
if (!socketInstance.connected && reason !== "io server disconnect") {
setTimeout(() => {
if (socketInstance.disconnected) {
console.log("Manually triggering reconnection...");
socketInstance.connect();
}
}, 2000); // Retry after 2 seconds
}
};
// Register event handlers
socketInstance.on("connect", handleConnect);
socketInstance.on("reconnect", handleReconnect);
socketInstance.on("connect_error", handleConnectionError);
socketInstance.on("disconnect", handleDisconnect);
socketInstance.on("bodyshop-message", handleBodyshopMessage);
};
const unsubscribe = auth.onIdTokenChanged(async (user) => {
if (user) {
const newToken = await user.getIdToken();
const token = await user.getIdToken();
if (socketRef.current) {
// Send new token to server
socketRef.current.emit("update-token", newToken);
} else if (bodyshop && bodyshop.id) {
// Initialize the socket
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
const socketInstance = SocketIO(endpoint, {
path: "/wss",
withCredentials: true,
auth: { token: newToken },
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000
});
socketRef.current = socketInstance;
const handleBodyshopMessage = (message) => {
if (!message || !message?.type) return;
switch (message.type) {
case "alert-update":
store.dispatch(addAlerts(message.payload));
break;
}
if (!import.meta.env.DEV) return;
console.log(`Received message for bodyshop ${bodyshop.id}:`, message);
};
const handleConnect = () => {
console.log("Socket connected:", socketInstance.id);
socketInstance.emit("join-bodyshop-room", bodyshop.id);
setClientId(socketInstance.id);
store.dispatch(setWssStatus("connected"));
};
const handleReconnect = (attempt) => {
console.log(`Socket reconnected after ${attempt} attempts`);
store.dispatch(setWssStatus("connected"));
};
const handleConnectionError = (err) => {
console.error("Socket connection error:", err);
store.dispatch(setWssStatus("error"));
};
const handleDisconnect = () => {
console.log("Socket disconnected");
store.dispatch(setWssStatus("disconnected"));
};
socketInstance.on("connect", handleConnect);
socketInstance.on("reconnect", handleReconnect);
socketInstance.on("connect_error", handleConnectionError);
socketInstance.on("disconnect", handleDisconnect);
socketInstance.on("bodyshop-message", handleBodyshopMessage);
// Update token if socket exists
socketRef.current.emit("update-token", token);
} else {
// Initialize socket if not already connected
initializeSocket(token);
}
} else {
// User is not authenticated
@@ -81,7 +109,7 @@ const useSocket = (bodyshop) => {
}
});
// Clean up the listener on unmount
// Clean up on unmount
return () => {
unsubscribe();
if (socketRef.current) {

View File

@@ -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);

View File

@@ -59,6 +59,8 @@ export const GET_CONVERSATION_DETAILS = gql`
id
phone_num
archived
updated_at
unreadcnt
label
job_conversations {
jobid
@@ -71,6 +73,17 @@ export const GET_CONVERSATION_DETAILS = gql`
ro_number
}
}
messages(order_by: { created_at: asc_nulls_first }) {
id
status
text
isoutbound
image
image_path
userid
created_at
read
}
}
}
`;
@@ -79,9 +92,26 @@ export const CONVERSATION_ID_BY_PHONE = gql`
query CONVERSATION_ID_BY_PHONE($phone: String!) {
conversations(where: { phone_num: { _eq: $phone } }) {
id
phone_num
archived
label
unreadcnt
created_at
job_conversations {
jobid
id
conversationid
job {
id
ownr_fn
ownr_ln
ownr_co_nm
ro_number
}
}
messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
aggregate {
count
}
}
}
}
@@ -92,6 +122,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
}
}
}
}
}

View File

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

View File

@@ -2,7 +2,11 @@ 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,
CREATE_CONVERSATION,
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 +31,73 @@ export function* onOpenChatByPhone() {
export function* openChatByPhone({ payload }) {
logImEXEvent("messaging_open_by_phone");
const { phone_num, jobid } = payload;
const { socket, phone_num, jobid } = payload;
if (!socket || !phone_num) return;
const p = parsePhoneNumber(phone_num, "CA");
const bodyshop = yield select(selectBodyshop);
try {
// Fetch conversations including archived ones
const {
data: { conversations }
} = yield client.query({
query: CONVERSATION_ID_BY_PHONE,
variables: { phone: p.number },
fetchPolicy: 'no-cache'
fetchPolicy: "no-cache" // Ensure the query always gets the latest data
});
if (conversations.length === 0) {
// Sort conversations by `updated_at` or `created_at` and pick the last one for the given phone number
const sortedConversations = conversations
?.filter((c) => c.phone_num === p.number) // Filter to match the phone number
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); // Sort by `updated_at`
const existingConversation = sortedConversations?.[sortedConversations.length - 1] || null;
if (existingConversation) {
let updatedConversation = existingConversation;
if (existingConversation.archived) {
// If the conversation is archived, unarchive it
const {
data: { update_conversations_by_pk: unarchivedConversation }
} = yield client.mutate({
mutation: TOGGLE_CONVERSATION_ARCHIVE,
variables: {
id: existingConversation.id,
archived: false
}
});
updatedConversation = unarchivedConversation;
// Emit an event indicating the conversation was unarchived
socket.emit("conversation-modified", {
type: "conversation-unarchived",
conversationId: unarchivedConversation.id,
bodyshopId: bodyshop.id,
archived: false
});
}
// Set the unarchived or already active conversation as selected
yield put(setSelectedConversation(updatedConversation.id));
// Add job tag if needed
if (jobid && !updatedConversation.job_conversations.find((jc) => jc.jobid === jobid)) {
yield client.mutate({
mutation: INSERT_CONVERSATION_TAG,
variables: {
conversationId: updatedConversation.id,
jobId: jobid
}
});
}
} else {
// 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 +111,21 @@ 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))
yield client.mutate({
mutation: INSERT_CONVERSATION_TAG,
variables: {
conversationId: conversations[0].id,
jobId: jobid
}
});
} else {
console.log("ERROR: Multiple conversations found. ");
yield put(setSelectedConversation(null));
const createdConversation = newConversations[0];
// Emit event for the new conversation with full details
socket.emit("conversation-modified", {
bodyshopId: bodyshop.id,
type: "conversation-created",
...createdConversation
});
// Set the newly created conversation as selected
yield put(setSelectedConversation(createdConversation.id));
}
} catch (error) {
console.log("Error in sendMessage saga.", error);
console.error("Error in openChatByPhone saga.", error);
}
}

View File

@@ -149,6 +149,39 @@ const cache = new InMemoryCache({
fields: {
conversations: offsetLimitPagination()
}
},
conversations: {
fields: {
job_conversations: {
keyArgs: false, // Indicates that all job_conversations share the same key
merge(existing = [], incoming) {
// Merge existing and incoming job_conversations
const merged = [
...existing,
...incoming.filter(
(incomingItem) => !existing.some((existingItem) => existingItem.__ref === incomingItem.__ref)
)
];
return merged;
}
},
messages: {
keyArgs: false, // Ignore arguments when determining uniqueness (like `order_by`).
merge(existing = [], incoming = [], { readField }) {
const existingIds = new Set(existing.map((message) => readField("id", message)));
// Merge incoming messages, avoiding duplicates
const merged = [...existing];
incoming.forEach((message) => {
if (!existingIds.has(readField("id", message))) {
merged.push(message);
}
});
return merged;
}
}
}
}
}
});

View File

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

View File

@@ -1,8 +1,5 @@
import react from "@vitejs/plugin-react";
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 { ViteEjsPlugin } from "vite-plugin-ejs";
import eslint from "vite-plugin-eslint";
@@ -18,28 +15,6 @@ process.env.VITE_APP_GIT_SHA_DATE = new Date().toLocaleString("en-US", {
const getFormattedTimestamp = () =>
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", {
allowClearScreen: false
});
@@ -108,7 +83,6 @@ export default defineConfig({
gcm_sender_id: "103953800507"
}
}),
reactVirtualizedFix(),
react(),
eslint()
],

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

View File

@@ -4,6 +4,7 @@ query FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID($mssid: String!, $phone: String!) {
id
conversations(where: { phone_num: { _eq: $phone } }) {
id
created_at
}
}
}`;
@@ -87,6 +88,21 @@ mutation RECEIVE_MESSAGE($msg: [messages_insert_input!]!) {
updated_at
unreadcnt
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
created_at
@@ -116,6 +132,7 @@ mutation INSERT_MESSAGE($msg: [messages_insert_input!]!, $conversationid: uuid!)
id
archived
bodyshop {
id
imexshopid
}
created_at
@@ -144,6 +161,11 @@ mutation UPDATE_MESSAGE($msid: String!, $fields: messages_set_input!) {
update_messages(where: { msid: { _eq: $msid } }, _set: $fields) {
returning {
id
status
conversationid
conversation{
bodyshopid
}
}
}
}`;
@@ -1496,7 +1518,8 @@ exports.GET_JOB_BY_PK = `query GET_JOB_BY_PK($id: uuid!) {
}`;
//TODO:AIO The above query used to have parts order lines in it. Validate that this doesn't need it.
exports.QUERY_JOB_COSTING_DETAILS = ` query QUERY_JOB_COSTING_DETAILS($id: uuid!) {
exports.QUERY_JOB_COSTING_DETAILS = `
query QUERY_JOB_COSTING_DETAILS($id: uuid!) {
jobs_by_pk(id: $id) {
ro_number
clm_total
@@ -2546,3 +2569,44 @@ exports.GET_JOBS_BY_PKS = `query GET_JOBS_BY_PKS($ids: [uuid!]!) {
}
}
`;
exports.MARK_MESSAGES_AS_READ = `mutation MARK_MESSAGES_AS_READ($conversationId: uuid!) {
update_messages(where: { conversationid: { _eq: $conversationId } }, _set: { read: true }) {
returning {
id
}
affected_rows
}
}
`;
exports.CREATE_CONVERSATION = `mutation CREATE_CONVERSATION($conversation: [conversations_insert_input!]!) {
insert_conversations(objects: $conversation) {
returning {
id
phone_num
archived
label
unreadcnt
job_conversations {
jobid
conversationid
job {
id
ro_number
ownr_fn
ownr_ln
ownr_co_nm
}
}
messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
aggregate {
count
}
}
created_at
updated_at
}
}
}
`;

View File

@@ -13,6 +13,7 @@ const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLCl
const { taskAssignedEmail, tasksRemindEmail } = require("../email/tasksEmails");
const { canvastest } = require("../render/canvas-handler");
const { alertCheck } = require("../alerts/alertcheck");
const uuid = require("uuid").v4;
//Test route to ensure Express is responding.
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.");
});
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
router.post("/search", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, os.search);

View File

@@ -11,187 +11,162 @@ const logger = require("../utils/logger");
const InstanceManager = require("../utils/instanceMgr").default;
exports.receive = async (req, res) => {
//Perform request validation
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
logger.log("sms-inbound", "DEBUG", "api", null, {
const loggerData = {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body)
});
};
if (!!!req.body || !!!req.body.MessagingServiceSid || !!!req.body.SmsMessageSid) {
logger.log("sms-inbound", "DEBUG", "api", null, loggerData);
if (!req.body || !req.body.MessagingServiceSid || !req.body.SmsMessageSid) {
logger.log("sms-inbound-error", "ERROR", "api", null, {
...loggerData,
type: "malformed-request"
});
return res.status(400).json({ success: false, error: "Malformed Request" });
}
try {
// Step 1: Find the bodyshop and existing conversation
const response = await client.request(queries.FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, {
mssid: req.body.MessagingServiceSid,
phone: phone(req.body.From).phoneNumber
});
if (!response.bodyshops[0]) {
return res.status(400).json({ success: false, error: "No matching bodyshop" });
}
const bodyshop = response.bodyshops[0];
// Sort conversations by `updated_at` (or `created_at`) and pick the last one
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]
: null;
let conversationid;
let newMessage = {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body),
type: "malformed-request"
});
res.status(400);
res.json({ success: false, error: "Malformed Request" });
} else {
try {
const response = await client.request(queries.FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, {
mssid: req.body.MessagingServiceSid,
phone: phone(req.body.From).phoneNumber
});
isoutbound: false,
userid: null // Add additional fields as necessary
};
let newMessage = {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body)
};
if (response.bodyshops[0]) {
//Found a bodyshop - should always happen.
if (response.bodyshops[0].conversations.length === 0) {
//No conversation Found, create one.
//console.log("[SMS Receive] No conversation found. Creating one.");
newMessage.conversation = {
data: {
bodyshopid: response.bodyshops[0].id,
phone_num: phone(req.body.From).phoneNumber
}
};
} else if (response.bodyshops[0].conversations.length === 1) {
//Just add it to the conversation
//console.log("[SMS Receive] Conversation found. Added ID.");
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 || ""
};
if (existingConversation) {
// Use the existing conversation
conversationid = existingConversation.id;
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
//imageUrl: "https://thinkimex.com/img/io-fcm.png", //TODO:AIO Resolve addresses for other instances
},
data
});
logger.log("sms-inbound-success", "DEBUG", "api", null, {
newMessage,
fcmresp
});
res.status(200).send("");
} catch (e2) {
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: e2
});
res.sendStatus(500).json(e2);
}
// Unarchive the conversation if necessary
if (existingConversation.archived) {
await client.request(queries.UNARCHIVE_CONVERSATION, {
id: conversationid,
archived: false
});
}
} 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
} else {
// Create a new conversation
const newConversationResponse = await client.request(queries.CREATE_CONVERSATION, {
conversation: {
bodyshopid: bodyshop.id,
phone_num: phone(req.body.From).phoneNumber,
archived: false
}
});
res.sendStatus(500).json(e1);
const createdConversation = newConversationResponse.insert_conversations.returning[0];
conversationid = createdConversation.id;
}
// Ensure `conversationid` is added to the message
newMessage.conversationid = conversationid;
// Step 3: Insert the message into the conversation
const insertresp = await client.request(queries.INSERT_MESSAGE, {
msg: newMessage,
conversationid: conversationid
});
const message = insertresp?.insert_messages?.returning?.[0];
const conversation = message?.conversation || null;
if (!conversation) {
throw new Error("Conversation data is missing from the response.");
}
// Step 4: Notify clients through Redis
const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id);
const conversationRoom = getBodyshopConversationRoom({
bodyshopId: conversation.bodyshop.id,
conversationId: conversation.id
});
const commonPayload = {
isoutbound: false,
conversationId: conversation.id,
updated_at: message.updated_at,
msid: message.sid
};
ioRedis.to(broadcastRoom).emit("new-message-summary", {
...commonPayload,
existingConversation: !!existingConversation,
newConversation: !existingConversation ? conversation : null,
summary: true
});
ioRedis.to(conversationRoom).emit("new-message-detailed", {
newMessage: message,
...commonPayload,
newConversation: !existingConversation ? conversation : null,
existingConversation: !!existingConversation,
summary: false
});
// Step 5: Send FCM notification
const fcmresp = await admin.messaging().send({
topic: `${message.conversation.bodyshop.imexshopid}-messaging`,
notification: {
title: InstanceManager({
imex: `ImEX Online Message - ${message.conversation.phone_num}`,
rome: `Rome Online Message - ${message.conversation.phone_num}`,
promanager: `ProManager Message - ${message.conversation.phone_num}`
}),
body: message.image_path ? `Image ${message.text}` : message.text
},
data: {
type: "messaging-inbound",
conversationid: message.conversationid || "",
text: message.text || "",
messageid: message.id || "",
phone_num: message.conversation.phone_num || ""
}
});
logger.log("sms-inbound-success", "DEBUG", "api", null, {
newMessage,
fcmresp
});
res.status(200).send("");
} catch (e) {
handleError(req, e, res, "RECEIVE_MESSAGE");
}
};
// 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 { NumMedia } = body;
if (parseInt(NumMedia) > 0) {
//stuff
const ret = [];
for (var i = 0; i < parseInt(NumMedia); i++) {
for (let i = 0; i < parseInt(NumMedia); i++) {
ret.push(body[`MediaUrl${i}`]);
}
return ret;
@@ -199,3 +174,17 @@ const generateMediaArray = (body) => {
return null;
}
};
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" });
};

View File

@@ -8,15 +8,17 @@ const { phone } = require("phone");
const queries = require("../graphql-client/queries");
const logger = require("../utils/logger");
const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY);
const { admin } = require("../firebase/firebase-handler");
const gqlClient = require("../graphql-client/graphql-client").client;
exports.send = (req, res) => {
exports.send = async (req, res) => {
const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body;
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
logger.log("sms-outbound", "DEBUG", req.user.email, null, {
messagingServiceSid: messagingServiceSid,
messagingServiceSid,
to: phone(to).phoneNumber,
mediaUrl: selectedMedia.map((i) => i.src),
text: body,
@@ -27,66 +29,10 @@ exports.send = (req, res) => {
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
});
if (!!to && !!messagingServiceSid && (!!body || !!selectedMedia.length > 0) && !!conversationid) {
client.messages
.create({
body: body,
messagingServiceSid: messagingServiceSid,
to: phone(to).phoneNumber,
mediaUrl: selectedMedia.map((i) => i.src)
})
.then((message) => {
let newMessage = {
msid: message.sid,
text: body,
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) : []
};
gqlClient
.request(queries.INSERT_MESSAGE, { msg: newMessage, conversationid })
.then((r2) => {
//console.log("Responding GQL Message ID", JSON.stringify(r2));
logger.log("sms-outbound-success", "DEBUG", req.user.email, null, {
msid: message.sid,
conversationid
});
const data = {
type: "messaging-outbound",
conversationid: newMessage.conversationid || ""
};
admin.messaging().send({
topic: `${imexshopid}-messaging`,
data
});
res.sendStatus(200);
})
.catch((e2) => {
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
msid: message.sid,
conversationid,
error: e2
});
//res.json({ success: false, message: e2 });
});
})
.catch((e1) => {
//res.json({ success: false, message: error });
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
conversationid,
error: e1
});
});
} else {
if (!to || !messagingServiceSid || (!body && selectedMedia.length === 0) || !conversationid) {
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
type: "missing-parameters",
messagingServiceSid: messagingServiceSid,
messagingServiceSid,
to: phone(to).phoneNumber,
text: body,
conversationid,
@@ -96,5 +42,72 @@ exports.send = (req, res) => {
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
});
res.status(400).json({ success: false, message: "Missing required parameter(s)." });
return;
}
try {
const message = await client.messages.create({
body,
messagingServiceSid,
to: phone(to).phoneNumber,
mediaUrl: selectedMedia.map((i) => i.src)
});
const newMessage = {
msid: message.sid,
text: body,
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) : []
};
try {
const gqlResponse = await gqlClient.request(queries.INSERT_MESSAGE, { msg: newMessage, conversationid });
logger.log("sms-outbound-success", "DEBUG", req.user.email, null, {
msid: message.sid,
conversationid
});
const insertedMessage = gqlResponse.insert_messages.returning[0];
const broadcastRoom = getBodyshopRoom(insertedMessage.conversation.bodyshop.id);
const conversationRoom = getBodyshopConversationRoom({
bodyshopId: insertedMessage.conversation.bodyshop.id,
conversationId: insertedMessage.conversation.id
});
ioRedis.to(broadcastRoom).emit("new-message-summary", {
isoutbound: true,
conversationId: conversationid,
updated_at: insertedMessage.updated_at,
msid: message.sid,
summary: true
});
ioRedis.to(conversationRoom).emit("new-message-detailed", {
newMessage: insertedMessage,
conversationId: conversationid,
summary: false
});
res.sendStatus(200);
} catch (gqlError) {
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
msid: message.sid,
conversationid,
error: gqlError.message,
stack: gqlError.stack
});
res.status(500).json({ success: false, message: "Failed to insert message into database." });
}
} catch (twilioError) {
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
conversationid,
error: twilioError.message,
stack: twilioError.stack
});
res.status(500).json({ success: false, message: "Failed to send message through Twilio." });
}
};

View File

@@ -5,59 +5,102 @@ require("dotenv").config({
const client = require("../graphql-client/graphql-client").client;
const queries = require("../graphql-client/queries");
const { phone } = require("phone");
const logger = require("../utils/logger");
const { admin } = require("../firebase/firebase-handler");
exports.status = (req, res) => {
exports.status = async (req, res) => {
const { SmsSid, SmsStatus } = req.body;
client
.request(queries.UPDATE_MESSAGE_STATUS, {
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
try {
// Ignore status 'queued'
if (SmsStatus === "queued") {
return res.status(200).json({ message: "Status 'queued' disregarded." });
}
// Update message status in the database
const response = await client.request(queries.UPDATE_MESSAGE_STATUS, {
msid: SmsSid,
fields: { status: SmsStatus }
})
.then((response) => {
});
const message = response.update_messages.returning[0];
if (message) {
logger.log("sms-status-update", "DEBUG", "api", null, {
msid: SmsSid,
fields: { status: SmsStatus }
});
})
.catch((error) => {
logger.log("sms-status-update-error", "ERROR", "api", null, {
// Emit WebSocket event to notify the change in message status
const conversationRoom = getBodyshopConversationRoom({
bodyshopId: message.conversation.bodyshopid,
conversationId: message.conversationid
});
ioRedis.to(conversationRoom).emit("message-changed", {
...message,
status: SmsStatus,
type: "status-changed"
});
} else {
logger.log("sms-status-update-warning", "WARN", "api", null, {
msid: SmsSid,
fields: { status: SmsStatus },
error
warning: "No message returned from the database update."
});
}
res.sendStatus(200);
} catch (error) {
logger.log("sms-status-update-error", "ERROR", "api", null, {
msid: SmsSid,
fields: { status: SmsStatus },
stack: error.stack,
message: error.message
});
res.sendStatus(200);
res.status(500).json({ error: "Failed to update message status." });
}
};
exports.markConversationRead = async (req, res) => {
const { conversationid, imexshopid } = req.body;
admin.messaging().send({
topic: `${imexshopid}-messaging`,
// notification: {
// title: `ImEX Online Message - ${data.phone_num}`,
// body: message.image_path ? `Image ${message.text}` : message.text,
// imageUrl: "https://thinkimex.com/img/logo512.png",
// },
data: {
type: "messaging-mark-conversation-read",
conversationid: conversationid || ""
}
});
res.send(200);
};
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
const { conversation, imexshopid, bodyshopid } = req.body;
// Inbound Sample
// {
// "SmsSid": "SM5205ea340e06437799d9345e7283457c",
// "SmsStatus": "queued",
// "MessageStatus": "queued",
// "To": "+16049992002",
// "MessagingServiceSid": "MG6e259e2add04ffa0d0aa355038670ee1",
// "MessageSid": "SM5205ea340e06437799d9345e7283457c",
// "AccountSid": "AC6c09d337d6b9c68ab6488c2052bd457c",
// "From": "+16043301606",
// "ApiVersion": "2010-04-01"
// }
// Alternatively, support both payload formats
const conversationId = conversation?.id || req.body.conversationId;
if (!conversationId || !imexshopid || !bodyshopid) {
return res.status(400).json({ error: "Invalid conversation data provided." });
}
try {
const response = await client.request(queries.MARK_MESSAGES_AS_READ, {
conversationId
});
const updatedMessageIds = response.update_messages.returning.map((message) => message.id);
const broadcastRoom = getBodyshopRoom(bodyshopid);
ioRedis.to(broadcastRoom).emit("conversation-changed", {
type: "conversation-marked-read",
conversationId,
affectedMessages: response.update_messages.affected_rows,
messageIds: updatedMessageIds
});
res.status(200).json({
success: true,
message: "Conversation marked as read."
});
} catch (error) {
console.error("Error marking conversation as read:", error);
res.status(500).json({ error: "Failed to mark conversation as read." });
}
};

View File

@@ -1,8 +1,12 @@
const applyIOHelpers = ({ app, api, io, logger }) => {
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 = {
getBodyshopRoom
getBodyshopRoom,
getBodyshopConversationRoom
};
// Helper middleware

View File

@@ -3,7 +3,7 @@ const { admin } = require("../firebase/firebase-handler");
const redisSocketEvents = ({
io,
redisHelpers: { setSessionData, clearSessionData }, // Note: Used if we persist user to Redis
ioHelpers: { getBodyshopRoom },
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom },
logger
}) => {
// Logging helper functions
@@ -46,18 +46,25 @@ const redisSocketEvents = ({
// Token Update Events
const registerUpdateEvents = (socket) => {
let latestTokenTimestamp = 0;
const updateToken = async (newToken) => {
const currentTimestamp = Date.now();
latestTokenTimestamp = currentTimestamp;
try {
// noinspection UnnecessaryLocalVariableJS
// Verify token with Firebase Admin SDK
const user = await admin.auth().verifyIdToken(newToken, true);
// Skip outdated token validations
if (currentTimestamp < latestTokenTimestamp) {
createLogEvent(socket, "warn", "Outdated token validation skipped.");
return;
}
socket.user = user;
// If We ever want to persist user Data across workers
// await setSessionData(socket.id, "user", user);
// Uncomment for further testing
// createLogEvent(socket, "debug", "Token updated successfully");
createLogEvent(socket, "debug", `Token updated successfully for socket ID: ${socket.id}`);
socket.emit("token-updated", { success: true });
} catch (error) {
if (error.code === "auth/id-token-expired") {
@@ -66,16 +73,20 @@ const redisSocketEvents = ({
success: false,
error: "Stale token."
});
} else {
createLogEvent(socket, "error", `Token update failed: ${error.message}`);
socket.emit("token-updated", { success: false, error: error.message });
// For any other errors, optionally disconnect the socket
socket.disconnect();
return; // Avoid disconnecting for expired tokens
}
createLogEvent(socket, "error", `Token update failed for socket ID: ${socket.id}, Error: ${error.message}`);
socket.emit("token-updated", { success: false, error: error.message });
// Optionally disconnect for invalid tokens or other errors
socket.disconnect();
}
};
socket.on("update-token", updateToken);
};
// Room Broadcast Events
const registerRoomAndBroadcastEvents = (socket) => {
const joinBodyshopRoom = (bodyshopUUID) => {
@@ -113,6 +124,7 @@ const redisSocketEvents = ({
socket.on("leave-bodyshop-room", leaveBodyshopRoom);
socket.on("broadcast-to-bodyshop", broadcastToBodyshopRoom);
};
// Disconnect Events
const registerDisconnectEvents = (socket) => {
const disconnect = () => {
@@ -130,9 +142,64 @@ const redisSocketEvents = ({
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("Failed to Join Conversation Room", "error", "io-redis", null, {
bodyshopId,
conversationId,
error: error.message,
stack: error.stack
});
}
};
const leaveConversationRoom = ({ bodyshopId, conversationId }) => {
try {
const room = getBodyshopConversationRoom({ bodyshopId, conversationId });
socket.leave(room);
} catch (error) {
logger.log("Failed to Leave Conversation Room", "error", "io-redis", null, {
bodyshopId,
conversationId,
error: error.message,
stack: error.stack
});
}
};
const conversationModified = ({ bodyshopId, conversationId, ...fields }) => {
try {
// Retrieve the room name for the conversation
const room = getBodyshopRoom(bodyshopId);
// Emit the updated data to all clients in the room
io.to(room).emit("conversation-changed", {
conversationId,
...fields
});
} catch (error) {
logger.log("Failed to handle conversation modification", "error", "io-redis", null, {
bodyshopId,
conversationId,
fields,
error: error.message,
stack: error.stack
});
}
};
socket.on("conversation-modified", conversationModified);
socket.on("join-bodyshop-conversation", joinConversationRoom);
socket.on("leave-bodyshop-conversation", leaveConversationRoom);
};
// Call Handlers
registerRoomAndBroadcastEvents(socket);
registerUpdateEvents(socket);
registerMessagingEvents(socket);
registerDisconnectEvents(socket);
};