Merged in release/2024-11-22 (pull request #1955)

Release/2024 11 22
This commit is contained in:
Patrick Fic
2024-11-26 18:48:52 +00:00
75 changed files with 14725 additions and 14133 deletions

View File

@@ -1,4 +1,4 @@
<babeledit_project version="1.2" be_version="2.7.1"> <babeledit_project be_version="2.7.1" version="1.2">
<!-- <!--
BabelEdit project file BabelEdit project file
@@ -20702,6 +20702,27 @@
</translation> </translation>
</translations> </translations>
</concept_node> </concept_node>
<concept_node>
<name>time</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>total</name> <name>total</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>
@@ -21874,6 +21895,48 @@
<folder_node> <folder_node>
<name>columns</name> <name>columns</name>
<children> <children>
<concept_node>
<name>average_human_readable</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>average_value</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>duration</name> <name>duration</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>
@@ -31809,6 +31872,27 @@
</translation> </translation>
</translations> </translations>
</concept_node> </concept_node>
<concept_node>
<name>tlos_ind</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>towin</name> <name>towin</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>
@@ -36358,6 +36442,27 @@
</translation> </translation>
</translations> </translations>
</concept_node> </concept_node>
<concept_node>
<name>total_repairs_cash_discount</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>total_sales</name> <name>total_sales</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>

12
certs/io-ftp-test.key Normal file
View File

@@ -0,0 +1,12 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAArAAAABNlY2RzYS
1zaGEyLW5pc3RwNTIxAAAACG5pc3RwNTIxAAAAhQQBYJnAujo17diR0fM2Ze1d1Ft6XHm5
U31pXdFEN+rGC4SoYTdZE8q3relxMS5GwwBOvgvVUuayfid2XS8ls/CMDiMBJAYqEK4CRY
PbbPB7lLnMWsF7muFhvs+SIpPQC+vtDwM2TKlxF0Y8p+iVRpvCADoggsSze7skmJWKmMTt
8jEdEOcAAAEQIyXsOSMl7DkAAAATZWNkc2Etc2hhMi1uaXN0cDUyMQAAAAhuaXN0cDUyMQ
AAAIUEAWCZwLo6Ne3YkdHzNmXtXdRbelx5uVN9aV3RRDfqxguEqGE3WRPKt63pcTEuRsMA
Tr4L1VLmsn4ndl0vJbPwjA4jASQGKhCuAkWD22zwe5S5zFrBe5rhYb7PkiKT0Avr7Q8DNk
ypcRdGPKfolUabwgA6IILEs3u7JJiVipjE7fIxHRDnAAAAQUO5dO9G7i0bxGTP0zV3eIwv
5g0NhrQJfW/bMHS6XWwaxdpr+QZ+DbBJVzZPwYC0wLMW4bJAf+kjqUnj4wGocoTeAAAAD2
lvLWZ0cC10ZXN0LWtleQECAwQ=
-----END OPENSSH PRIVATE KEY-----

View File

@@ -0,0 +1 @@
ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAFgmcC6OjXt2JHR8zZl7V3UW3pceblTfWld0UQ36sYLhKhhN1kTyret6XExLkbDAE6+C9VS5rJ+J3ZdLyWz8IwOIwEkBioQrgJFg9ts8HuUucxawXua4WG+z5Iik9AL6+0PAzZMqXEXRjyn6JVGm8IAOiCCxLN7uySYlYqYxO3yMR0Q5w== io-ftp-test-key

12
certs/io-ftp-test.ppk Normal file
View File

@@ -0,0 +1,12 @@
PuTTY-User-Key-File-3: ecdsa-sha2-nistp521
Encryption: none
Comment: io-ftp-test-key
Public-Lines: 4
AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAFgmcC6OjXt
2JHR8zZl7V3UW3pceblTfWld0UQ36sYLhKhhN1kTyret6XExLkbDAE6+C9VS5rJ+
J3ZdLyWz8IwOIwEkBioQrgJFg9ts8HuUucxawXua4WG+z5Iik9AL6+0PAzZMqXEX
Rjyn6JVGm8IAOiCCxLN7uySYlYqYxO3yMR0Q5w==
Private-Lines: 2
AAAAQUO5dO9G7i0bxGTP0zV3eIwv5g0NhrQJfW/bMHS6XWwaxdpr+QZ+DbBJVzZP
wYC0wLMW4bJAf+kjqUnj4wGocoTe
Private-MAC: d67001d47e13c43dc8bdb9c68a25356a96c1c4a6714f3c5a1836fca646b78b54

View File

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

View File

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

View File

@@ -1,89 +1,30 @@
import { useApolloClient } from "@apollo/client"; import { useApolloClient } from "@apollo/client";
import { getToken, onMessage } from "@firebase/messaging"; import React, { useContext, useEffect } from "react";
import { Button, notification, Space } from "antd";
import axios from "axios";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next"; 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 ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss"; import "./chat-affix.styles.scss";
import SocketContext from "../../contexts/SocketIO/socketContext";
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
export function ChatAffixContainer({ bodyshop, chatVisible }) { export function ChatAffixContainer({ bodyshop, chatVisible }) {
const { t } = useTranslation(); const { t } = useTranslation();
const client = useApolloClient(); const client = useApolloClient();
const { socket } = useContext(SocketContext);
useEffect(() => { useEffect(() => {
if (!bodyshop || !bodyshop.messagingservicesid) return; if (!bodyshop || !bodyshop.messagingservicesid) return;
async function SubscribeToTopic() { //Register WS handlers
try { if (socket && socket.connected) {
const r = await axios.post("/notifications/subscribe", { registerMessagingHandlers({ socket, client });
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>
)
});
}
} }
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 () => { return () => {
stopMessageListener && stopMessageListener(); if (socket && socket.connected) {
channel && channel.removeEventListener("message", handleMessage); unregisterMessagingHandlers({ socket });
}
}; };
}, [client]); }, [bodyshop, socket, t, client]);
if (!bodyshop || !bodyshop.messagingservicesid) return <></>; 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 { useMutation } from "@apollo/client";
import { Button } from "antd"; import { Button } from "antd";
import React, { useState } from "react"; import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries"; 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 [loading, setLoading] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const [updateConversation] = useMutation(TOGGLE_CONVERSATION_ARCHIVE); const [updateConversation] = useMutation(TOGGLE_CONVERSATION_ARCHIVE);
const { socket } = useContext(SocketContext);
const handleToggleArchive = async () => { const handleToggleArchive = async () => {
setLoading(true); setLoading(true);
await updateConversation({ const updatedConversation = await updateConversation({
variables: { id: conversation.id, archived: !conversation.archived }, variables: { id: conversation.id, archived: !conversation.archived }
refetchQueries: ["CONVERSATION_LIST_QUERY"]
}); });
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); setLoading(false);
}; };
@@ -25,3 +45,5 @@ export default function ChatArchiveButton({ conversation }) {
</Button> </Button>
); );
} }
export default connect(mapStateToProps, mapDispatchToProps)(ChatArchiveButton);

View File

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

View File

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

View File

@@ -1,13 +1,24 @@
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { Tag } from "antd"; import { Tag } from "antd";
import React from "react"; import React, { useContext } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries"; import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component"; 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 [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
const { socket } = useContext(SocketContext);
const handleRemoveTag = (jobId) => { const handleRemoveTag = (jobId) => {
const convId = jobConversations[0].conversationid; 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", { logImEXEvent("messaging_remove_job_tag", {
conversationId: convId, conversationId: convId,
@@ -54,3 +75,5 @@ export default function ChatConversationTitleTags({ jobConversations }) {
</div> </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 ChatLabelComponent from "../chat-label/chat-label.component";
import ChatPrintButton from "../chat-print-button/chat-print-button.component"; import ChatPrintButton from "../chat-print-button/chat-print-button.component";
import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container"; 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 ( return (
<Space wrap> <Space wrap>
<PhoneNumberFormatter>{conversation && conversation.phone_num}</PhoneNumberFormatter> <PhoneNumberFormatter>{conversation && conversation.phone_num}</PhoneNumberFormatter>
@@ -19,3 +25,5 @@ export default function ChatConversationTitle({ conversation }) {
</Space> </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 ChatSendMessage from "../chat-send-message/chat-send-message.component";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component.jsx"; import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component.jsx";
import "./chat-conversation.styles.scss"; 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; const [loading, error] = subState;
if (conversation?.archived) return null;
if (loading) return <LoadingSkeleton />; if (loading) return <LoadingSkeleton />;
if (error) return <AlertComponent message={error.message} type="error" />; if (error) return <AlertComponent message={error.message} type="error" />;
@@ -18,9 +34,11 @@ export default function ChatConversationComponent({ subState, conversation, mess
onMouseDown={handleMarkConversationAsRead} onMouseDown={handleMarkConversationAsRead}
onKeyDown={handleMarkConversationAsRead} onKeyDown={handleMarkConversationAsRead}
> >
<ChatConversationTitle conversation={conversation} /> <ChatConversationTitle conversation={conversation} bodyshop={bodyshop} />
<ChatMessageListComponent messages={messages} /> <ChatMessageListComponent messages={messages} />
<ChatSendMessage conversation={conversation} /> <ChatSendMessage conversation={conversation} />
</div> </div>
); );
} }
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationComponent);

View File

@@ -1,22 +1,24 @@
import { useMutation, useQuery, useSubscription } from "@apollo/client"; import { useApolloClient, useQuery } from "@apollo/client";
import React, { useState } from "react"; import axios from "axios";
import React, { useCallback, useContext, useEffect, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries"; import SocketContext from "../../contexts/SocketIO/socketContext";
import { MARK_MESSAGES_AS_READ_BY_CONVERSATION } from "../../graphql/messages.queries"; import { GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors"; import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import ChatConversationComponent from "./chat-conversation.component";
import axios from "axios";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatConversationComponent from "./chat-conversation.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation, selectedConversation: selectSelectedConversation,
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
export default connect(mapStateToProps, null)(ChatConversationContainer);
export function ChatConversationContainer({ bodyshop, selectedConversation }) { export function ChatConversationContainer({ bodyshop, selectedConversation }) {
const client = useApolloClient();
const { socket } = useContext(SocketContext);
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
const { const {
loading: convoLoading, loading: convoLoading,
error: convoError, error: convoError,
@@ -27,55 +29,123 @@ export function ChatConversationContainer({ bodyshop, selectedConversation }) {
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only"
}); });
const { loading, error, data } = useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, { const updateCacheWithReadMessages = useCallback(
variables: { conversationId: selectedConversation } (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, { // Update aggregate unread count for the conversation
variables: { conversationId: selectedConversation }, client.cache.modify({
refetchQueries: ["UNREAD_CONVERSATION_COUNT"], id: client.cache.identify({ __typename: "conversations", id: conversationId }),
update(cache) {
cache.modify({
id: cache.identify({
__typename: "conversations",
id: selectedConversation
}),
fields: { fields: {
messages_aggregate(cached) { messages_aggregate(existingAggregate) {
return { aggregate: { count: 0 } }; return {
...existingAggregate,
aggregate: {
...existingAggregate.aggregate,
count: 0 // No unread messages remaining
}
};
} }
} }
}); });
} },
}); [client.cache]
);
const unreadCount = // Handle WebSocket events
data && useEffect(() => {
data.messages && if (!socket || !socket.connected) return;
data.messages.reduce((acc, val) => {
return !val.read && !val.isoutbound ? acc + 1 : acc;
}, 0);
const handleMarkConversationAsRead = async () => { const handleConversationChange = (data) => {
if (unreadCount > 0 && !!selectedConversation && !markingAsReadInProgress) { if (data.type === "conversation-marked-read") {
setMarkingAsReadInProgress(true); const { conversationId, messageIds } = data;
await markConversationRead({}); console.log("Conversation change received:", data);
await axios.post("/sms/markConversationRead", { updateCacheWithReadMessages(conversationId, messageIds);
conversationid: selectedConversation, }
imexshopid: bodyshop.imexshopid };
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 ( return (
<ChatConversationComponent <ChatConversationComponent
subState={[loading || convoLoading, error || convoError]} subState={[convoLoading, convoError]}
conversation={convoData ? convoData.conversations_by_pk : {}} conversation={convoData ? convoData.conversations_by_pk : {}}
messages={data ? data.messages : []} messages={convoData ? convoData.conversations_by_pk.messages : []}
handleMarkConversationAsRead={handleMarkConversationAsRead} handleMarkConversationAsRead={handleMarkConversationAsRead}
/> />
); );
} }
export default connect(mapStateToProps, null)(ChatConversationContainer);

View File

@@ -1,14 +1,25 @@
import { PlusOutlined } from "@ant-design/icons"; import { PlusOutlined } from "@ant-design/icons";
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { Input, notification, Spin, Tag, Tooltip } from "antd"; 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 { useTranslation } from "react-i18next";
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries"; 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 [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [value, setValue] = useState(conversation.label); const [value, setValue] = useState(conversation.label);
const { socket } = useContext(SocketContext);
const { t } = useTranslation(); const { t } = useTranslation();
const [updateLabel] = useMutation(UPDATE_CONVERSATION_LABEL); const [updateLabel] = useMutation(UPDATE_CONVERSATION_LABEL);
@@ -26,6 +37,14 @@ export default function ChatLabel({ conversation }) {
}) })
}); });
} else { } else {
if (socket) {
socket.emit("conversation-modified", {
type: "label-updated",
conversationId: conversation.id,
bodyshopId: bodyshop.id,
label: value
});
}
setEditing(false); setEditing(false);
} }
} catch (error) { } 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 React, { useEffect, useRef } from "react";
import { MdDone, MdDoneAll } from "react-icons/md"; import { Virtuoso } from "react-virtuoso";
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from "react-virtualized"; import { renderMessage } from "./renderMessage";
import { DateTimeFormatter } from "../../utils/DateFormatter";
import "./chat-message-list.styles.scss"; import "./chat-message-list.styles.scss";
const SCROLL_DELAY_MS = 50;
const INITIAL_SCROLL_DELAY_MS = 100;
export default function ChatMessageListComponent({ messages }) { export default function ChatMessageListComponent({ messages }) {
const virtualizedListRef = useRef(null); const virtuosoRef = useRef(null);
const _cache = new CellMeasurerCache({ // Scroll to the bottom after a short delay when the component mounts
fixedWidth: true, useEffect(() => {
// minHeight: 50, const timer = setTimeout(() => {
defaultHeight: 100 if (virtuosoRef?.current?.scrollToIndex && messages?.length) {
}); virtuosoRef.current.scrollToIndex({
index: messages.length - 1,
behavior: "auto" // Instantly scroll to the bottom
});
}
}, INITIAL_SCROLL_DELAY_MS);
const scrollToBottom = (renderedrows) => { // Cleanup the timeout on unmount
//console.log("Scrolling to", messages.length); return () => clearTimeout(timer);
// !!virtualizedListRef.current && // eslint-disable-next-line react-hooks/exhaustive-deps
// virtualizedListRef.current.scrollToRow(messages.length); }, []); // ESLint is disabled for this line because we only want this to load once (valid exception)
// Outstanding isue on virtualization: https://github.com/bvaughn/react-virtualized/issues/1179
//Scrolling does not work on this version of React.
};
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 }) => { // Cleanup timeout on dependency changes
return ( return () => clearTimeout(timeout);
<CellMeasurer cache={_cache} key={key} rowIndex={index} parent={parent}> }
{({ measure, registerChild }) => ( }, [messages]); // Triggered when new messages are added
<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>
);
};
return ( return (
<div className="chat"> <div className="chat">
<AutoSizer> <Virtuoso
{({ height, width }) => ( ref={virtuosoRef}
<List data={messages}
ref={virtualizedListRef} itemContent={(index) => renderMessage(messages, index)} // Pass `messages` to renderMessage
width={width} followOutput="smooth" // Ensure smooth scrolling when new data is appended
height={height} style={{ height: "100%", width: "100%" }}
rowHeight={_cache.rowHeight} />
rowRenderer={_rowRenderer}
rowCount={messages.length}
overscanRowCount={10}
estimatedRowSize={150}
scrollToIndex={messages.length}
/>
)}
</AutoSizer>
</div> </div>
); );
} }
const MessageRender = (message) => {
return (
<Tooltip title={DateTimeFormatter({ children: message.created_at })}>
<div>
{message.image_path &&
message.image_path.map((i, idx) => (
<div key={idx} style={{ display: "flex", justifyContent: "center" }}>
<a href={i} target="__blank">
<img alt="Received" className="message-img" src={i} />
</a>
</div>
))}
<div>{message.text}</div>
</div>
</Tooltip>
);
};
const StatusRender = (status) => {
switch (status) {
case "sent":
return <Icon component={MdDone} className="message-icon" />;
case "delivered":
return <Icon component={MdDoneAll} className="message-icon" />;
default:
return null;
}
};

View File

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

View File

@@ -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 { PlusCircleFilled } from "@ant-design/icons";
import { Button, Form, Popover } from "antd"; import { Button, Form, Popover } from "antd";
import React from "react"; import React, { useContext } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { openChatByPhone } from "../../redux/messaging/messaging.actions"; import { openChatByPhone } from "../../redux/messaging/messaging.actions";
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component"; import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser //currentUser: selectCurrentUser
@@ -17,8 +18,10 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatNewConversation({ openChatByPhone }) { export function ChatNewConversation({ openChatByPhone }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const { socket } = useContext(SocketContext);
const handleFinish = (values) => { const handleFinish = (values) => {
openChatByPhone({ phone_num: values.phoneNumber }); openChatByPhone({ phone_num: values.phoneNumber, socket });
form.resetFields(); form.resetFields();
}; };

View File

@@ -1,6 +1,6 @@
import { notification } from "antd"; import { notification } from "antd";
import parsePhoneNumber from "libphonenumber-js"; import parsePhoneNumber from "libphonenumber-js";
import React from "react"; import React, { useContext } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { openChatByPhone } from "../../redux/messaging/messaging.actions"; import { openChatByPhone } from "../../redux/messaging/messaging.actions";
@@ -9,6 +9,7 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { searchingForConversation } from "../../redux/messaging/messaging.selectors"; import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -21,6 +22,8 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobid, openChatByPhone }) { export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobid, openChatByPhone }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { socket } = useContext(SocketContext);
if (!phone) return <></>; if (!phone) return <></>;
if (!bodyshop.messagingservicesid) return <PhoneNumberFormatter>{phone}</PhoneNumberFormatter>; if (!bodyshop.messagingservicesid) return <PhoneNumberFormatter>{phone}</PhoneNumberFormatter>;
@@ -33,7 +36,7 @@ export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobi
const p = parsePhoneNumber(phone, "CA"); const p = parsePhoneNumber(phone, "CA");
if (searchingForConversation) return; //This is to prevent finding the same thing twice. if (searchingForConversation) return; //This is to prevent finding the same thing twice.
if (p && p.isValid()) { if (p && p.isValid()) {
openChatByPhone({ phone_num: p.formatInternational(), jobid: jobid }); openChatByPhone({ phone_num: p.formatInternational(), jobid: jobid, socket });
} else { } else {
notification["error"]({ message: t("messaging.error.invalidphone") }); notification["error"]({ message: t("messaging.error.invalidphone") });
} }

View File

@@ -1,11 +1,11 @@
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons"; 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 { 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 { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; 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 { toggleChatVisible } from "../../redux/messaging/messaging.actions";
import { selectChatVisible, selectSelectedConversation } from "../../redux/messaging/messaging.selectors"; import { selectChatVisible, selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import ChatConversationListComponent from "../chat-conversation-list/chat-conversation-list.component"; 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 ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import "./chat-popup.styles.scss"; import "./chat-popup.styles.scss";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation, selectedConversation: selectSelectedConversation,
chatVisible: selectChatVisible chatVisible: selectChatVisible
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
toggleChatVisible: () => dispatch(toggleChatVisible()) toggleChatVisible: () => dispatch(toggleChatVisible())
}); });
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) { export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
const { t } = useTranslation(); 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, { // Lazy query for conversations
fetchPolicy: "network-only", const [getConversations, { loading, data, refetch }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
nextFetchPolicy: "network-only",
...(pollInterval > 0 ? { pollInterval } : {})
});
const [getConversations, { loading, data, refetch, fetchMore }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
skip: !chatVisible, skip: !chatVisible,
...(pollInterval > 0 ? { pollInterval } : {}) ...(pollInterval > 0 ? { pollInterval } : {})
}); });
const fcmToken = sessionStorage.getItem("fcmtoken"); // Socket connection status
useEffect(() => { useEffect(() => {
if (fcmToken) { const handleSocketStatus = () => {
setpollInterval(0); if (socket?.connected) {
} else { setPollInterval(15 * 60 * 1000); // 15 minutes
setpollInterval(90000); } else {
} setPollInterval(60 * 1000); // 60 seconds
}, [fcmToken]); }
};
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(() => { useEffect(() => {
if (chatVisible) if (chatVisible)
getConversations({ getConversations({
variables: { variables: {
offset: 0 offset: 0
} }
}).catch((err) => {
console.error(`Error fetching conversations: ${(err, err.message || "")}`);
}); });
}, [chatVisible, getConversations]); }, [chatVisible, getConversations]);
const loadMoreConversations = useCallback(() => { // Get unread count from the cache
if (data) const unreadCount = (() => {
fetchMore({ try {
variables: { const cachedData = client.readQuery({
offset: data.conversations.length 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 ( return (
<Badge count={unreadCount}> <Badge count={unreadCount}>
@@ -93,10 +120,7 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
{loading ? ( {loading ? (
<LoadingSpinner /> <LoadingSpinner />
) : ( ) : (
<ChatConversationListComponent <ChatConversationListComponent conversationList={data ? data.conversations : []} />
conversationList={data ? data.conversations : []}
loadMoreConversations={loadMoreConversations}
/>
)} )}
</Col> </Col>
<Col span={16}>{selectedConversation ? <ChatConversationContainer /> : null}</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 }) { function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) {
const inputArea = useRef(null); const inputArea = useRef(null);
const [selectedMedia, setSelectedMedia] = useState([]); const [selectedMedia, setSelectedMedia] = useState([]);
useEffect(() => { useEffect(() => {
inputArea.current.focus(); inputArea.current.focus();
}, [isSending, setMessage]); }, [isSending, setMessage]);
@@ -37,14 +38,15 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
logImEXEvent("messaging_send_message"); logImEXEvent("messaging_send_message");
if (selectedImages.length < 11) { if (selectedImages.length < 11) {
sendMessage({ const newMessage = {
to: conversation.phone_num, to: conversation.phone_num,
body: message || "", body: message || "",
messagingServiceSid: bodyshop.messagingservicesid, messagingServiceSid: bodyshop.messagingservicesid,
conversationid: conversation.id, conversationid: conversation.id,
selectedMedia: selectedImages, selectedMedia: selectedImages,
imexshopid: bodyshop.imexshopid imexshopid: bodyshop.imexshopid
}); };
sendMessage(newMessage);
setSelectedMedia( setSelectedMedia(
selectedMedia.map((i) => { selectedMedia.map((i) => {
return { ...i, isSelected: false }; return { ...i, isSelected: false };

View File

@@ -2,16 +2,27 @@ import { PlusOutlined } from "@ant-design/icons";
import { useLazyQuery, useMutation } from "@apollo/client"; import { useLazyQuery, useMutation } from "@apollo/client";
import { Tag } from "antd"; import { Tag } from "antd";
import _ from "lodash"; import _ from "lodash";
import React, { useState } from "react"; import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries"; import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries"; import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
import ChatTagRo from "./chat-tag-ro.component"; 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 { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { socket } = useContext(SocketContext);
const [loadRo, { loading, data }] = useLazyQuery(SEARCH_FOR_JOBS); const [loadRo, { loading, data }] = useLazyQuery(SEARCH_FOR_JOBS);
@@ -32,7 +43,31 @@ export default function ChatTagRoContainer({ conversation }) {
const handleInsertTag = (value, option) => { const handleInsertTag = (value, option) => {
logImEXEvent("messaging_add_job_tag"); 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); setOpen(false);
}; };
@@ -60,3 +95,5 @@ export default function ChatTagRoContainer({ conversation }) {
</div> </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 parsePhoneNumber from "libphonenumber-js";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import queryString from "query-string"; import queryString from "query-string";
import React, { useState } from "react"; import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom"; 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 { useMutation } from "@apollo/client";
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries"; import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component"; import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -49,6 +50,8 @@ export function ScheduleEventComponent({
const searchParams = queryString.parse(useLocation().search); const searchParams = queryString.parse(useLocation().search);
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT); const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
const [title, setTitle] = useState(event.title); const [title, setTitle] = useState(event.title);
const { socket } = useContext(SocketContext);
const blockContent = ( const blockContent = (
<Space direction="vertical" wrap> <Space direction="vertical" wrap>
<Input <Input
@@ -190,7 +193,8 @@ export function ScheduleEventComponent({
if (p && p.isValid()) { if (p && p.isValid()) {
openChatByPhone({ openChatByPhone({
phone_num: p.formatInternational(), phone_num: p.formatInternational(),
jobid: event.job.id jobid: event.job.id,
socket
}); });
setMessage( setMessage(
t("appointments.labels.reminder", { t("appointments.labels.reminder", {

View File

@@ -1,5 +1,5 @@
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { Button, Form, notification, Popover, Tooltip } from "antd"; import { Button, Checkbox, Form, notification, Popover, Tooltip } from "antd";
import axios from "axios"; import axios from "axios";
import { t } from "i18next"; import { t } from "i18next";
import React, { useState } from "react"; import React, { useState } from "react";
@@ -60,24 +60,26 @@ export function JobLinesPartPriceChange({ job, line, refetch, technician }) {
} }
}; };
const popcontent = !technician && InstanceRenderManager({ const popcontent =
imex: null, !technician &&
rome: ( InstanceRenderManager({
<Form layout="vertical" onFinish={handleFinish} initialValues={{ act_price: line.act_price }}> imex: null,
<Form.Item name="act_price" label={t("jobs.labels.act_price_ppc")} rules={[{ required: true }]}> rome: (
<CurrencyFormItemComponent /> <Form layout="vertical" onFinish={handleFinish} initialValues={{ act_price: line.act_price }}>
</Form.Item> <Form.Item name="act_price" label={t("jobs.labels.act_price_ppc")} rules={[{ required: true }]}>
<Button <CurrencyFormItemComponent />
disabled={InstanceRenderManager({ imex: true, rome: false, promanager: true })} </Form.Item>
loading={loading} <Button
htmlType="primary" disabled={InstanceRenderManager({ imex: true, rome: false, promanager: true })}
> loading={loading}
{t("general.actions.save")} htmlType="primary"
</Button> >
</Form> {t("general.actions.save")}
), </Button>
promanager: null </Form>
}); ),
promanager: null
});
return ( return (
<JobLineConvertToLabor jobline={line} job={job}> <JobLineConvertToLabor jobline={line} job={job}>

View File

@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectJobReadOnly } from "../../redux/application/application.selectors"; import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { setModalContext } from "../../redux/modals/modals.actions"; import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; import CurrencyFormatter from "../../utils/CurrencyFormatter";
@@ -26,21 +25,16 @@ const mapStateToProps = createStructuredSelector({
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "payment" })), setPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "payment" })),
setCardPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "cardPayment" })), setCardPaymentContext: (context) =>
openChatByPhone: (phone) => dispatch(openChatByPhone(phone)), dispatch(
setMessage: (text) => dispatch(setMessage(text)) setModalContext({
context: context,
modal: "cardPayment"
})
)
}); });
export function JobPayments({ export function JobPayments({ job, jobRO, bodyshop, setPaymentContext, setCardPaymentContext, refetch }) {
job,
jobRO,
bodyshop,
setMessage,
openChatByPhone,
setPaymentContext,
setCardPaymentContext,
refetch
}) {
const { const {
treatments: { ImEXPay } treatments: { ImEXPay }
} = useSplitTreatments({ } = 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(() => { const total = useMemo(() => {
return ( return (
job.payments && job.payments &&

View File

@@ -7,6 +7,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import JobTotalsCashDiscount from "./jobs-totals.cash-discount-display.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser //currentUser: selectCurrentUser
@@ -22,6 +23,14 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
const data = useMemo(() => { const data = useMemo(() => {
return [ return [
...(job.job_totals?.totals?.ttl_adjustment
? [
{
key: `Subtotal Adj.`,
total: job.job_totals?.totals?.ttl_adjustment
}
]
: []),
{ {
key: t("jobs.labels.subtotal"), key: t("jobs.labels.subtotal"),
total: job.job_totals.totals.subtotal, total: job.job_totals.totals.subtotal,
@@ -102,7 +111,7 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
total: job.job_totals.totals.us_sales_tax_breakdown.ty4Tax total: job.job_totals.totals.us_sales_tax_breakdown.ty4Tax
}, },
{ {
key: `${bodyshop.md_responsibility_centers.taxes.tax_ty5?.tax_type5 || "TT"} - ${[ key: `${bodyshop.md_responsibility_centers.taxes.tax_ty5?.tax_type5 || "Adj."} - ${[
job.cieca_pft.ty5_rate1, job.cieca_pft.ty5_rate1,
job.cieca_pft.ty5_rate2, job.cieca_pft.ty5_rate2,
job.cieca_pft.ty5_rate3, job.cieca_pft.ty5_rate3,
@@ -113,6 +122,14 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
.join(", ")}%`, .join(", ")}%`,
total: job.job_totals.totals.us_sales_tax_breakdown.ty5Tax total: job.job_totals.totals.us_sales_tax_breakdown.ty5Tax
}, },
...(job.job_totals?.totals?.ttl_tax_adjustment
? [
{
key: `Tax Adj.`,
total: job.job_totals?.totals?.ttl_tax_adjustment
}
]
: []),
{ {
key: t("jobs.labels.total_sales_tax"), key: t("jobs.labels.total_sales_tax"),
bold: true, bold: true,
@@ -121,6 +138,7 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
.add(Dinero(job.job_totals.totals.us_sales_tax_breakdown.ty3Tax)) .add(Dinero(job.job_totals.totals.us_sales_tax_breakdown.ty3Tax))
.add(Dinero(job.job_totals.totals.us_sales_tax_breakdown.ty4Tax)) .add(Dinero(job.job_totals.totals.us_sales_tax_breakdown.ty4Tax))
.add(Dinero(job.job_totals.totals.us_sales_tax_breakdown.ty5Tax)) .add(Dinero(job.job_totals.totals.us_sales_tax_breakdown.ty5Tax))
.add(Dinero(job.job_totals.totals.ttl_tax_adjustment))
.toJSON() .toJSON()
} }
].filter((item) => item.total.amount !== 0) ].filter((item) => item.total.amount !== 0)
@@ -132,11 +150,27 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
] ]
}), }),
{ ...(bodyshop.intellipay_config?.enable_cash_discount
key: t("jobs.labels.total_repairs"), ? [
total: job.job_totals.totals.total_repairs, {
bold: true key: t("jobs.labels.total_repairs_cash_discount"),
}, total: job.job_totals.totals.total_repairs,
bold: true
},
{
key: t("jobs.labels.total_repairs"),
render: <JobTotalsCashDiscount amountDinero={job.job_totals.totals.total_repairs} />,
bold: true
}
]
: [
{
key: t("jobs.labels.total_repairs"),
total: job.job_totals.totals.total_repairs,
bold: true
}
]),
{ {
key: t("jobs.fields.ded_amt"), key: t("jobs.fields.ded_amt"),
total: job.job_totals.totals.custPayable.deductible total: job.job_totals.totals.custPayable.deductible
@@ -169,13 +203,7 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
}, },
{ {
key: t("jobs.labels.total_cust_payable"), key: t("jobs.labels.total_cust_payable"),
total: Dinero(job.job_totals.totals.custPayable.total) render: <JobTotalsCashDiscount amountDinero={job.job_totals.totals.custPayable.total} />,
.add(
Dinero(job.job_totals.totals.custPayable.total).percentage(
bodyshop.intellipay_config?.cash_discount_percentage || 0
)
)
.toJSON(),
bold: true bold: true
} }
] ]
@@ -211,7 +239,7 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
dataIndex: "total", dataIndex: "total",
key: "total", key: "total",
align: "right", align: "right",
render: (text, record) => Dinero(record.total).toFormat(), render: (text, record) => (record.render ? record.render : Dinero(record.total).toFormat()),
width: "20%", width: "20%",
onCell: (record, rowIndex) => { onCell: (record, rowIndex) => {
return { style: { fontWeight: record.bold && "bold" } }; return { style: { fontWeight: record.bold && "bold" } };

View File

@@ -0,0 +1,60 @@
import { notification, Spin } from "antd";
import axios from "axios";
import Dinero from "dinero.js";
import React, { useCallback, useEffect, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({});
export default connect(mapStateToProps, mapDispatchToProps)(JobTotalsCashDiscount);
export function JobTotalsCashDiscount({ bodyshop, amountDinero }) {
const [loading, setLoading] = useState(true);
const [fee, setFee] = useState(0);
const fetchData = useCallback(async () => {
if (amountDinero && bodyshop) {
setLoading(true);
let response;
try {
response = await axios.post("/intellipay/checkfee", {
bodyshop: { id: bodyshop.id, imexshopid: bodyshop.imexshopid, state: bodyshop.state },
amount: Dinero(amountDinero).toFormat("0.00")
});
if (response?.data?.error) {
notification.open({
type: "error",
message:
response.data?.error ||
"Error encountered when contacting IntelliPay service to determine cash discounted price."
});
} else {
setFee(response.data?.fee || 0);
}
} catch (error) {
notification.open({
type: "error",
message:
error.response?.data?.error ||
"Error encountered when contacting IntelliPay service to determine cash discounted price."
});
} finally {
setLoading(false);
}
}
}, [amountDinero, bodyshop]);
useEffect(() => {
fetchData();
}, [fetchData, bodyshop, amountDinero]);
if (loading) return <Spin size="small" />;
return Dinero(amountDinero)
.add(Dinero({ amount: Math.round(fee * 100) }))
.toFormat();
}

View File

@@ -1,6 +1,6 @@
import { gql, useApolloClient, useLazyQuery, useMutation, useQuery } from "@apollo/client"; import { gql, useApolloClient, useLazyQuery, useMutation, useQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Col, Row, notification } from "antd"; import { Button, Col, Row, notification } from "antd";
import Axios from "axios"; import Axios from "axios";
import _ from "lodash"; import _ from "lodash";
import queryString from "query-string"; import queryString from "query-string";
@@ -409,24 +409,23 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
setSchComp={setSchComp} setSchComp={setSchComp}
/> />
{ {
// currentUser.email.includes("@rome.") || {/* currentUser.email.includes("@rome.") || currentUser.email.includes("@imex.") ? (
// currentUser.email.includes("@imex.") ? ( <Button
// <Button onClick={async () => {
// onClick={async () => { for (const record of data.available_jobs) {
// for (const record of data.available_jobs) { //Query the data
// //Query the data console.log("Start Job", record.id);
// console.log("Start Job", record.id); const { data } = await loadEstData({
// const {data} = await loadEstData({ variables: { id: record.id }
// variables: {id: record.id}, });
// }); console.log("Query has been awaited and is complete");
// console.log("Query has been awaited and is complete"); await onOwnerFindModalOk(data);
// await onOwnerFindModalOk(data); }
// } }}
// }} >
// > Add all jobs as new.
// Add all jobs as new. </Button>
// </Button> ) : null */}
// ) : null
} }
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col span={24}> <Col span={24}>
@@ -617,6 +616,7 @@ function ResolveCCCLineIssues(estData, bodyshop) {
// ` | Act Price delete. (prev act price = ${estData.joblines.data[indexInEstData].act_price})`; // ` | Act Price delete. (prev act price = ${estData.joblines.data[indexInEstData].act_price})`;
estData.joblines.data[indexInEstData].act_price = 0; estData.joblines.data[indexInEstData].act_price = 0;
estData.joblines.data[indexInEstData].db_price = 0; estData.joblines.data[indexInEstData].db_price = 0;
estData.joblines.data[indexInEstData].part_type = null;
}); });
}); });
} }

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 { Button, Card, Dropdown, Form, Input, Modal, notification, Popconfirm, Popover, Select, Space } from "antd";
import axios from "axios"; import axios from "axios";
import parsePhoneNumber from "libphonenumber-js"; import parsePhoneNumber from "libphonenumber-js";
import React, { useMemo, useState } from "react"; import React, { useContext, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link, useNavigate } from "react-router-dom"; 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 AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util"; import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production"; import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -126,6 +127,7 @@ export function JobsDetailHeaderActions({
const [updateJob] = useMutation(UPDATE_JOB); const [updateJob] = useMutation(UPDATE_JOB);
const [voidJob] = useMutation(VOID_JOB); const [voidJob] = useMutation(VOID_JOB);
const [cancelAllAppointments] = useMutation(CANCEL_APPOINTMENTS_BY_JOB_ID); const [cancelAllAppointments] = useMutation(CANCEL_APPOINTMENTS_BY_JOB_ID);
const { socket } = useContext(SocketContext);
const { const {
treatments: { ImEXPay } treatments: { ImEXPay }
@@ -299,7 +301,8 @@ export function JobsDetailHeaderActions({
if (p && p.isValid()) { if (p && p.isValid()) {
openChatByPhone({ openChatByPhone({
phone_num: p.formatInternational(), phone_num: p.formatInternational(),
jobid: job.id jobid: job.id,
socket
}); });
setMessage( setMessage(
`${window.location.protocol}//${window.location.host}/csi/${result.data.insert_csi.returning[0].id}` `${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()) { if (p && p.isValid()) {
openChatByPhone({ openChatByPhone({
phone_num: p.formatInternational(), phone_num: p.formatInternational(),
jobid: job.id jobid: job.id,
socket
}); });
setMessage(`${window.location.protocol}//${window.location.host}/csi/${job.csiinvites[0].id}`); setMessage(`${window.location.protocol}//${window.location.host}/csi/${job.csiinvites[0].id}`);
} else { } else {

View File

@@ -3,13 +3,14 @@ import { Button, Form, message, Popover, Space } from "antd";
import axios from "axios"; import axios from "axios";
import Dinero from "dinero.js"; import Dinero from "dinero.js";
import { parsePhoneNumber } from "libphonenumber-js"; import { parsePhoneNumber } from "libphonenumber-js";
import React, { useState } from "react"; import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions"; import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component"; import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -28,6 +29,7 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [paymentLink, setPaymentLink] = useState(null); const [paymentLink, setPaymentLink] = useState(null);
const { socket } = useContext(SocketContext);
const handleFinish = async ({ amount }) => { const handleFinish = async ({ amount }) => {
setLoading(true); setLoading(true);
@@ -50,7 +52,8 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
if (p) { if (p) {
openChatByPhone({ openChatByPhone({
phone_num: p.formatInternational(), phone_num: p.formatInternational(),
jobid: job.id jobid: job.id,
socket
}); });
setMessage( setMessage(
t("payments.labels.smspaymentreminder", { t("payments.labels.smspaymentreminder", {
@@ -106,7 +109,8 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
const p = parsePhoneNumber(job.ownr_ph1, "CA"); const p = parsePhoneNumber(job.ownr_ph1, "CA");
openChatByPhone({ openChatByPhone({
phone_num: p.formatInternational(), phone_num: p.formatInternational(),
jobid: job.id jobid: job.id,
socket
}); });
setMessage( setMessage(
t("payments.labels.smspaymentreminder", { t("payments.labels.smspaymentreminder", {

View File

@@ -4334,6 +4334,70 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
<Input /> <Input />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
{InstanceRenderManager({
promanager: "USE_ROME",
rome: (
<LayoutFormRow header={<div>Adjustments</div>} id="refund">
{bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber ? (
<>
<Form.Item
label={t("bodyshop.labels.responsibilitycenters.ttl_adjustment")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "ttl_adjustment", "dms_acctnumber"]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.labels.responsibilitycenters.ttl_tax_adjustment")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "ttl_tax_adjustment", "dms_acctnumber"]}
>
<Input />
</Form.Item>
</>
) : (
<>
<Form.Item
label={t("bodyshop.labels.responsibilitycenters.ttl_adjustment")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "ttl_adjustment", "accountitem"]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.labels.responsibilitycenters.ttl_tax_adjustment")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "ttl_tax_adjustment", "accountitem"]}
>
<Input />
</Form.Item>
</>
)}
</LayoutFormRow>
)
})}
{Qb_Multi_Ar.treatment === "on" && ( {Qb_Multi_Ar.treatment === "on" && (
<LayoutFormRow header={<div>Multiple Payers Item</div>} id="accountitem"> <LayoutFormRow header={<div>Multiple Payers Item</div>} id="accountitem">
<Form.Item <Form.Item

View File

@@ -37,16 +37,6 @@ export function ShopInfoIntellipay({ bodyshop, form }) {
> >
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item
label={t("bodyshop.fields.intellipay_config.cash_discount_percentage")}
dependencies={[["intellipay_config", "enable_cash_discount"]]}
name={["intellipay_config", "cash_discount_percentage"]}
rules={[
({ getFieldsValue }) => ({ required: form.getFieldValue(["intellipay_config", "enable_cash_discount"]) })
]}
>
<InputNumber min={0} max={100} precision={1} suffix="%" />
</Form.Item>
</LayoutFormRow> </LayoutFormRow>
</> </>
); );

View File

@@ -1,18 +1,33 @@
import { connect } from "react-redux"; import { connect } from "react-redux";
import { GlobalOutlined } from "@ant-design/icons"; import { GlobalOutlined, WarningOutlined } from "@ant-design/icons";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import React from "react"; import React from "react";
import { selectWssStatus } from "../../redux/application/application.selectors"; import { selectWssStatus } from "../../redux/application/application.selectors";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
wssStatus: selectWssStatus wssStatus: selectWssStatus
}); });
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) const mapDispatchToProps = (dispatch) => ({});
});
export default connect(mapStateToProps, mapDispatchToProps)(WssStatusDisplay);
export function WssStatusDisplay({ wssStatus }) { export function WssStatusDisplay({ wssStatus }) {
console.log("🚀 ~ WssStatusDisplay ~ wssStatus:", 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); const [clientId, setClientId] = useState(null);
useEffect(() => { 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) => { const unsubscribe = auth.onIdTokenChanged(async (user) => {
if (user) { if (user) {
const newToken = await user.getIdToken(); const token = await user.getIdToken();
if (socketRef.current) { if (socketRef.current) {
// Send new token to server // Update token if socket exists
socketRef.current.emit("update-token", newToken); socketRef.current.emit("update-token", token);
} else if (bodyshop && bodyshop.id) { } else {
// Initialize the socket // Initialize socket if not already connected
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : ""; initializeSocket(token);
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);
} }
} else { } else {
// User is not authenticated // User is not authenticated
@@ -81,7 +109,7 @@ const useSocket = (bodyshop) => {
} }
}); });
// Clean up the listener on unmount // Clean up on unmount
return () => { return () => {
unsubscribe(); unsubscribe();
if (socketRef.current) { if (socketRef.current) {

View File

@@ -4,7 +4,6 @@ import { getAuth, updatePassword, updateProfile } from "firebase/auth";
import { getFirestore } from "firebase/firestore"; import { getFirestore } from "firebase/firestore";
import { getMessaging, getToken, onMessage } from "firebase/messaging"; import { getMessaging, getToken, onMessage } from "firebase/messaging";
import { store } from "../redux/store"; import { store } from "../redux/store";
import axios from "axios";
const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG); const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
initializeApp(config); initializeApp(config);

View File

@@ -59,6 +59,8 @@ export const GET_CONVERSATION_DETAILS = gql`
id id
phone_num phone_num
archived archived
updated_at
unreadcnt
label label
job_conversations { job_conversations {
jobid jobid
@@ -71,6 +73,17 @@ export const GET_CONVERSATION_DETAILS = gql`
ro_number ro_number
} }
} }
messages(order_by: { created_at: asc_nulls_first }) {
id
status
text
isoutbound
image
image_path
userid
created_at
read
}
} }
} }
`; `;
@@ -79,9 +92,26 @@ export const CONVERSATION_ID_BY_PHONE = gql`
query CONVERSATION_ID_BY_PHONE($phone: String!) { query CONVERSATION_ID_BY_PHONE($phone: String!) {
conversations(where: { phone_num: { _eq: $phone } }) { conversations(where: { phone_num: { _eq: $phone } }) {
id id
phone_num
archived
label
unreadcnt
created_at
job_conversations { job_conversations {
jobid 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) { insert_conversations(objects: $conversation) {
returning { returning {
id 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(); fetchAlerts();
}, []); }, [setAlerts]);
// Use useEffect to watch for new alerts // Use useEffect to watch for new alerts
useEffect(() => { useEffect(() => {
@@ -647,7 +647,7 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
return ( return (
<> <>
{import.meta.env.PROD && <ChatAffixContainer bodyshop={bodyshop} chatVisible={chatVisible} />} <ChatAffixContainer bodyshop={bodyshop} chatVisible={chatVisible} />
<Layout style={{ minHeight: "100vh" }} className="layout-container"> <Layout style={{ minHeight: "100vh" }} className="layout-container">
<UpdateAlert /> <UpdateAlert />
<HeaderContainer /> <HeaderContainer />

View File

@@ -2,7 +2,11 @@ import axios from "axios";
import parsePhoneNumber from "libphonenumber-js"; import parsePhoneNumber from "libphonenumber-js";
import { all, call, put, select, takeLatest } from "redux-saga/effects"; import { all, call, put, select, takeLatest } from "redux-saga/effects";
import { logImEXEvent } from "../../firebase/firebase.utils"; 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 { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import client from "../../utils/GraphQLClient"; import client from "../../utils/GraphQLClient";
import { selectBodyshop } from "../user/user.selectors"; import { selectBodyshop } from "../user/user.selectors";
@@ -27,23 +31,73 @@ export function* onOpenChatByPhone() {
export function* openChatByPhone({ payload }) { export function* openChatByPhone({ payload }) {
logImEXEvent("messaging_open_by_phone"); 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 p = parsePhoneNumber(phone_num, "CA");
const bodyshop = yield select(selectBodyshop); const bodyshop = yield select(selectBodyshop);
try { try {
// Fetch conversations including archived ones
const { const {
data: { conversations } data: { conversations }
} = yield client.query({ } = yield client.query({
query: CONVERSATION_ID_BY_PHONE, query: CONVERSATION_ID_BY_PHONE,
variables: { phone: p.number }, 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 { const {
data: { data: {
insert_conversations: { returning: newConversationsId } insert_conversations: { returning: newConversations }
} }
} = yield client.mutate({ } = yield client.mutate({
mutation: CREATE_CONVERSATION, 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. const createdConversation = newConversations[0];
if (jobid && !conversations[0].job_conversations.find((jc) => jc.jobid === jobid))
yield client.mutate({ // Emit event for the new conversation with full details
mutation: INSERT_CONVERSATION_TAG, socket.emit("conversation-modified", {
variables: { bodyshopId: bodyshop.id,
conversationId: conversations[0].id, type: "conversation-created",
jobId: jobid ...createdConversation
} });
});
} else { // Set the newly created conversation as selected
console.log("ERROR: Multiple conversations found. "); yield put(setSelectedConversation(createdConversation.id));
yield put(setSelectedConversation(null));
} }
} catch (error) { } catch (error) {
console.log("Error in sendMessage saga.", error); console.error("Error in openChatByPhone saga.", error);
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

14
docker-build.ps1 Normal file
View File

@@ -0,0 +1,14 @@
# Stop and remove all containers, images, and networks from the Compose file
docker compose down --rmi all
# Prune all unused Docker objects including volumes
docker system prune --all --volumes --force
# Prune unused build cache
docker builder prune --all --force
# Prune all unused volumes
docker volume prune --all --force
# Rebuild and start the containers
docker compose up --build

16
docker-build.sh Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
# Stop and remove all containers, images, and networks from the Compose file
docker compose down --rmi all
# Prune all unused Docker objects including volumes
docker system prune --all --volumes --force
# Prune unused build cache
docker builder prune --all --force
# Prune all unused volumes
docker volume prune --all --force
# Rebuild and start the containers
docker compose up --build

View File

@@ -114,7 +114,7 @@ services:
" "
aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1 aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1 aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/id_rsa aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1 aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1 aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
" "
@@ -178,9 +178,10 @@ services:
# ports: # ports:
# - "2222:22" # Expose port 22 for SSH/SFTP (mapped to 2222 on the host) # - "2222:22" # Expose port 22 for SSH/SFTP (mapped to 2222 on the host)
# volumes: # volumes:
# - ./certs/id_rsa.pub:/home/user/.ssh/keys/id_rsa.pub:ro # Mount the SSH public key # - ./certs/io-ftp-test.key.pub:/home/user/.ssh/keys/io-ftp-test.key.pub:ro # Mount the SSH public key as authorized_keys
# - ./upload:/home/user/upload # Mount a local directory for SFTP uploads # - ./upload:/home/user/upload # Mount a local directory for SFTP uploads
# environment: # environment:
# # - SFTP_USERS=user::1000::upload
# - SFTP_USERS=user:password:1000::upload # - SFTP_USERS=user:password:1000::upload
# command: > # command: >
# /bin/sh -c " # /bin/sh -c "

View File

@@ -6,6 +6,15 @@
headers: headers:
- name: x-imex-auth - name: x-imex-auth
value_from_env: DATAPUMP_AUTH value_from_env: DATAPUMP_AUTH
- name: Chatter Data Pump
webhook: '{{HASURA_API_URL}}/data/chatter'
schedule: 45 5 * * *
include_in_metadata: true
payload: {}
headers:
- name: x-imex-auth
value_from_env: DATAPUMP_AUTH
comment: ""
- name: Claimscorp Data Pump - name: Claimscorp Data Pump
webhook: '{{HASURA_API_URL}}/data/cc' webhook: '{{HASURA_API_URL}}/data/cc'
schedule: 30 6 * * * schedule: 30 6 * * *

View File

@@ -1,14 +1,12 @@
const path = require("path"); const path = require("path");
const fs = require("fs");
const Dinero = require("dinero.js"); const Dinero = require("dinero.js");
const { gql } = require("graphql-request"); const { gql } = require("graphql-request");
const queries = require("./server/graphql-client/queries");
const GraphQLClient = require("graphql-request").GraphQLClient;
const logger = require("./server/utils/logger");
const AxiosLib = require("axios").default; const AxiosLib = require("axios").default;
const axios = AxiosLib.create(); const axios = AxiosLib.create();
const pLimit = require("p-limit");
const converter = require("json-2-csv");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN"; Dinero.globalRoundingMode = "HALF_EVEN";
const client = require("./server/graphql-client/graphql-client").client; const client = require("./server/graphql-client/graphql-client").client;
require("dotenv").config({ require("dotenv").config({
@@ -16,8 +14,9 @@ require("dotenv").config({
}); });
async function RunTheTest() { async function RunTheTest() {
const bodyshopids = ["b501bb82-22b2-493a-8a0f-152938194869"]; const bodyshopids = ["71f8494c-89f0-43e0-8eb2-820b52d723bc"];
const bearerToken = `Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImJhNjI1OTZmNTJmNTJlZDQ0MDQ5Mzk2YmU3ZGYzNGQyYzY0ZjQ1M2UiLCJ0eXAiOiJKV1QifQ.eyJuYW1lIjoiUm9tZSBEZXZlbG9wbWVudCIsImh0dHBzOi8vaGFzdXJhLmlvL2p3dC9jbGFpbXMiOnsieC1oYXN1cmEtZGVmYXVsdC1yb2xlIjoidXNlciIsIngtaGFzdXJhLWFsbG93ZWQtcm9sZXMiOlsidXNlciJdLCJ4LWhhc3VyYS11c2VyLWlkIjoidDZZbTFORGxDRE9QWnIzRjliZ3VXSDRMaFNYMiJ9LCJpb2FkbWluIjp0cnVlLCJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vcm9tZS1wcm9kLTEiLCJhdWQiOiJyb21lLXByb2QtMSIsImF1dGhfdGltZSI6MTcxMDk1MTg1MCwidXNlcl9pZCI6InQ2WW0xTkRsQ0RPUFpyM0Y5Ymd1V0g0TGhTWDIiLCJzdWIiOiJ0NlltMU5EbENET1BacjNGOWJndVdINExoU1gyIiwiaWF0IjoxNzExNTczODI1LCJleHAiOjE3MTE1Nzc0MjUsImVtYWlsIjoicGF0cmlja0Byb21lLmRldiIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJlbWFpbCI6WyJwYXRyaWNrQHJvbWUuZGV2Il19LCJzaWduX2luX3Byb3ZpZGVyIjoicGFzc3dvcmQifX0.0kBySA9tJznLYj8TtncHGVWJO0IcmLKP2G1UyyXwaj45kTa25bjT9RWjM-NslX_zjOvrvmQZzisFAb6M1Jf6geNjOMLIqb8bhihhzEZK4CcRfvjT6cpZxnOO2Dp_1Y5OePbvOBS_GlfdsovVWa84OLuhYC5G_3QwHT8_2Cttz4CbrC6M_vd7QsGODJYBbVKMhOdZhzpNq7AbOUh3749WRjLMMobpnZDrmQlsyg3PAqtX1FHO25WQS2rma9QahGDSY736JfbkuZJ2XbNn0axEGpK7RQLUcuRkFUlfKqYplNbR_e1Q3kEfRAZpxBPXZysrDcbDNhbkWCoTmJ3fle55OA`; const bearerToken = `Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImI4Y2FjOTViNGE1YWNkZTBiOTY1NzJkZWU4YzhjOTVlZWU0OGNjY2QiLCJ0eXAiOiJKV1QifQ.eyJuYW1lIjoiUGF0cmljayBGaWMgKERFVikiLCJodHRwczovL2hhc3VyYS5pby9qd3QvY2xhaW1zIjp7IngtaGFzdXJhLWRlZmF1bHQtcm9sZSI6InVzZXIiLCJ4LWhhc3VyYS1hbGxvd2VkLXJvbGVzIjpbInVzZXIiXSwieC1oYXN1cmEtdXNlci1pZCI6ImhOSjhBRHB0REhRQkRFcXNCOFFNWVRqaURuZjEifSwiaXNzIjoiaHR0cHM6Ly9zZWN1cmV0b2tlbi5nb29nbGUuY29tL2ltZXgtZGV2IiwiYXVkIjoiaW1leC1kZXYiLCJhdXRoX3RpbWUiOjE3MzAxMzIwMjksInVzZXJfaWQiOiJoTko4QURwdERIUUJERXFzQjhRTVlUamlEbmYxIiwic3ViIjoiaE5KOEFEcHRESFFCREVxc0I4UU1ZVGppRG5mMSIsImlhdCI6MTczMDg0MTc2NSwiZXhwIjoxNzMwODQ1MzY1LCJlbWFpbCI6InBhdHJpY2tAaW1leC5kZXYiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsImZpcmViYXNlIjp7ImlkZW50aXRpZXMiOnsiZW1haWwiOlsicGF0cmlja0BpbWV4LmRldiJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn19.npQWkyB5cB4wmkaBsQiY3JbvBM9vKPqf3e22nVHnSydGcQi0p9M2mca9FcDtdcWvQlShUM63FF-6KkzpovC92sHauNmzCSXRInaaCPEussUUNSJEe2gEV03tYX447LkkSmFQbJ5V6qLTIDelm25fF0MoEDVnLTgythK_9927f8cxKZH1kEow0ymDeMaWey1sRyu7n15OJMcu692mfuQnBAArGTHGJ4YmReI7tMmdrV438MLxuVpH5CLb6uzlUdZoJ__7yh0kz0lkZEeHQAL8yq-0fISbPeZ5uXuMzYGrHuuKsIPRoeShVSVnF7ov8yTT3_YrCkhYbxl0eSTfBB5OdQ`;
const { jobs } = await client.request( const { jobs } = await client.request(
gql` gql`
query GET_JOBS($bodyshopids: [uuid!]!) { query GET_JOBS($bodyshopids: [uuid!]!) {
@@ -36,69 +35,98 @@ async function RunTheTest() {
const results = []; const results = [];
for (const [index, job] of jobs.entries()) { const limit = pLimit(5); // Set concurrency limit to 3
process.stdout.cursorTo(0);
process.stdout.write(
`Processing job ${index + 1} of ${jobs.length}. Failed jobs: ${results.filter((r) => r.result !== "PASS").length}`
);
try { const tasks = jobs.map((job, index) => {
await axios.post( return limit(async () => {
`http://localhost:4000/job/totalsssu`, process.stdout.cursorTo(0);
{ id: job.id }, process.stdout.write(
{ headers: { Authorization: bearerToken } } `Processing job ${index + 1} of ${jobs.length}. Failed jobs: ${results.filter((r) => r.overallTotalCorrect !== "PASS").length}. Correct jobs because of adjustment: ${results.filter((r) => r.correctJobsBecauseOfAdjustment).length}`
); );
const { jobs_by_pk: newjob } = await client.request(
gql` try {
query GET_JOBS($id: uuid!) { await axios.post(
jobs_by_pk(id: $id) { `http://localhost:4000/job/totalsssu`,
id { id: job.id },
ro_number { headers: { Authorization: bearerToken } }
cieca_ttl );
job_totals const { jobs_by_pk: newjob } = await client.request(
ownr_fn gql`
ownr_ln query GET_JOBS($id: uuid!) {
ownr_co_nm jobs_by_pk(id: $id) {
ins_co_nm id
comment ro_number
cieca_ttl
job_totals
ownr_fn
ownr_ln
ownr_co_nm
ins_co_nm
comment
}
} }
`,
{
id: job.id
} }
`, );
{
id: job.id const result = {
id: newjob.id,
owner: `${newjob.ownr_fn} ${newjob.ownr_ln} ${job.ownr_co_nm || ""}`,
ins_co: newjob.ins_co_nm,
comment: newjob.comment,
imexsubtotal: Dinero(newjob.job_totals.totals.subtotal).toFormat("0.00"),
imextotalrepair: Dinero(newjob.job_totals.totals.total_repairs).toFormat("0.00"),
g_tax: newjob.cieca_ttl.data.g_tax,
n_ttl_amt: newjob.cieca_ttl.data.n_ttl_amt,
g_ttl_amt: newjob.cieca_ttl.data.g_ttl_amt
};
const calcTotal = newjob.job_totals.totals.total_repairs.amount;
const ttlTotal = newjob.cieca_ttl.data.g_ttl_amt * 100;
result.difference = (calcTotal - ttlTotal) / 100;
if (Math.abs(calcTotal - ttlTotal) > 3) {
result.overallTotalCorrect = "***FAIL***";
} else {
result.overallTotalCorrect = "PASS";
} }
); result.ttl_adjustment = Dinero(newjob.job_totals.totals.ttl_adjustment).toFormat();
result.ttl_tax_adjustment = Dinero(newjob.job_totals.totals.ttl_tax_adjustment).toFormat();
const result = { const calcTaxDinero = Dinero(newjob.job_totals.totals.us_sales_tax_breakdown.ty1Tax)
id: newjob.id, .add(Dinero(newjob.job_totals.totals.us_sales_tax_breakdown.ty2Tax))
owner: `${newjob.ownr_fn} ${newjob.ownr_ln} ${job.ownr_co_nm || ""}`, .add(Dinero(newjob.job_totals.totals.us_sales_tax_breakdown.ty3Tax))
ins_co: newjob.ins_co_nm, .add(Dinero(newjob.job_totals.totals.us_sales_tax_breakdown.ty4Tax))
comment: newjob.comment .add(Dinero(newjob.job_totals.totals.us_sales_tax_breakdown.ty5Tax))
}; .add(Dinero(newjob.job_totals.totals.ttl_tax_adjustment));
result.calcTax = calcTaxDinero.toFormat("0.00");
const calcTax = calcTaxDinero.getAmount() / 100;
const emsTax = newjob.cieca_ttl.data.g_tax;
result.taxDifference = calcTax - emsTax;
const calcTotal = newjob.job_totals.totals.total_repairs.amount; if (Math.abs(calcTax - emsTax) > 3) {
const ttlTotal = newjob.cieca_ttl.data.g_ttl_amt * 100; result.taxCorrect = "***FAIL***";
result.difference = (calcTotal - ttlTotal) / 100; } else {
result.taxCorrect = "PASS";
}
if (Math.abs(calcTotal - ttlTotal) > 3) { results.push(result);
//Diff is greater than 5 cents. Fail it. } catch (error) {
result.result = "***FAIL***"; results.push({
} else { ro_number: job.ro_number,
result.result = "PASS"; id: job.id,
result: error.message
});
} }
// console.log(`${result.result} => RO ${job.ro_number} - ${job.id} `); });
});
results.push(result); await Promise.all(tasks);
} catch (error) {
results.push({
ro_number: job.ro_number,
id: job.id,
result: "**503 FAILURE**"
});
}
}
console.table(results.filter((r) => r.result !== "PASS")); console.table(results.filter((r) => r.overallTotalCorrect !== "PASS"));
console.log("=======================================");
const summary = results.reduce( const summary = results.reduce(
(acc, val) => { (acc, val) => {
if (val.result === "PASS") { if (val.result === "PASS") {
@@ -110,18 +138,12 @@ async function RunTheTest() {
{ pass: 0, fail: 0 } { pass: 0, fail: 0 }
); );
console.log("Pass Rate: ", ((summary.pass / (summary.fail + summary.pass)) * 100).toFixed(1)); console.log("Pass Rate: ", ((summary.pass / (summary.fail + summary.pass)) * 100).toFixed(1));
const ret = converter.json2csv(results, { emptyFieldValue: "" });
fs.writeFile(`./logs/totalstest-${Date.now()}.csv`, ret, (error) => console.log(error));
} }
RunTheTest(); RunTheTest().catch((error) => {
console.log("Error in RunTheTest: ", error);
// mutation { });
// delete_jobs(where: {shopid: {_eq: "a7ee1503-ee05-4a02-b80e-bdb11d1cc8ac"}}) {
// affected_rows
// }
// delete_owners(where: {shopid: {_eq: "a7ee1503-ee05-4a02-b80e-bdb11d1cc8ac"}}) {
// affected_rows
// }
// delete_vehicles(where: {shopid: {_eq: "a7ee1503-ee05-4a02-b80e-bdb11d1cc8ac"}}) {
// affected_rows
// }
// }

3244
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -22,6 +22,7 @@ const { applyIOHelpers } = require("./server/utils/ioHelpers");
const { redisSocketEvents } = require("./server/web-sockets/redisSocketEvents"); const { redisSocketEvents } = require("./server/web-sockets/redisSocketEvents");
const { ElastiCacheClient, DescribeCacheClustersCommand } = require("@aws-sdk/client-elasticache"); const { ElastiCacheClient, DescribeCacheClustersCommand } = require("@aws-sdk/client-elasticache");
const { InstanceRegion } = require("./server/utils/instanceMgr"); const { InstanceRegion } = require("./server/utils/instanceMgr");
const StartStatusReporter = require("./server/utils/statusReporter");
const CLUSTER_RETRY_BASE_DELAY = 100; const CLUSTER_RETRY_BASE_DELAY = 100;
const CLUSTER_RETRY_MAX_DELAY = 5000; const CLUSTER_RETRY_MAX_DELAY = 5000;
@@ -297,6 +298,8 @@ const main = async () => {
applyRoutes({ app }); applyRoutes({ app });
redisSocketEvents({ io: ioRedis, redisHelpers, ioHelpers, logger }); redisSocketEvents({ io: ioRedis, redisHelpers, ioHelpers, logger });
StartStatusReporter();
try { try {
await server.listen(port); await server.listen(port);
logger.log(`Server started on port ${port}`, "INFO", "api"); logger.log(`Server started on port ${port}`, "INFO", "api");

View File

@@ -548,6 +548,61 @@ exports.default = function ({ bodyshop, jobs_by_pk, qbo = false, items, taxCodes
} }
} }
if (jobs_by_pk.job_totals.totals?.ttl_adjustment) {
// Do not need to check for ImEX or Rome because ImEX uses a different totals calculation that will never set this field.
if (qbo) {
const taxAccountCode = findTaxCode(
{
local: false,
federal: InstanceManager({ imex: true, rome: false }),
state: jobs_by_pk.tax_lbr_rt === 0 ? false : true
},
bodyshop.md_responsibility_centers.sales_tax_codes
);
const QboTaxId = InstanceManager({
imex: taxCodes[taxAccountCode],
rome: CheckQBOUSATaxID({
// jobline: jobline,
type: "adjustment",
job: jobs_by_pk
})
});
InvoiceLineAdd.push({
DetailType: "SalesItemLineDetail",
Amount: Dinero(jobs_by_pk.job_totals.totals?.ttl_adjustment).toFormat(DineroQbFormat),
SalesItemLineDetail: {
...(jobs_by_pk.class ? { ClassRef: { value: classes[jobs_by_pk.class] } } : {}),
ItemRef: {
value: items[responsibilityCenters.ttl_adjustment?.accountitem]
},
TaxCodeRef: {
value: QboTaxId
},
Qty: 1
}
});
} else {
InvoiceLineAdd.push({
ItemRef: {
FullName: responsibilityCenters.ttl_adjustment?.accountitem
},
Desc: "Adjustment",
Quantity: 1,
Amount: Dinero(jobs_by_pk.job_totals.totals?.ttl_adjustment).toFormat(DineroQbFormat),
SalesTaxCodeRef: InstanceManager({
imex: {
FullName: "E"
},
rome: {
FullName: bodyshop.md_responsibility_centers.taxes.itemexemptcode || "NON"
}
})
});
}
}
//Add tax lines //Add tax lines
const job_totals = jobs_by_pk.job_totals; const job_totals = jobs_by_pk.job_totals;
const federal_tax = Dinero(job_totals.totals.federal_tax); const federal_tax = Dinero(job_totals.totals.federal_tax);
@@ -824,7 +879,60 @@ exports.default = function ({ bodyshop, jobs_by_pk, qbo = false, items, taxCodes
} }
} }
} }
if (jobs_by_pk.job_totals.totals.ttl_tax_adjustment) {
// Do not need to check for ImEX or Rome because ImEX uses a different totals calculation that will never set this field.
if (qbo) {
const taxAccountCode = findTaxCode(
{
local: false,
federal: InstanceManager({ imex: true, rome: false }),
state: jobs_by_pk.tax_lbr_rt === 0 ? false : true
},
bodyshop.md_responsibility_centers.sales_tax_codes
);
const QboTaxId = InstanceManager({
imex: taxCodes[taxAccountCode],
rome: CheckQBOUSATaxID({
// jobline: jobline,
type: "adjustment",
job: jobs_by_pk
})
});
InvoiceLineAdd.push({
DetailType: "SalesItemLineDetail",
Amount: Dinero(jobs_by_pk.job_totals.totals?.ttl_tax_adjustment).toFormat(DineroQbFormat),
SalesItemLineDetail: {
...(jobs_by_pk.class ? { ClassRef: { value: classes[jobs_by_pk.class] } } : {}),
ItemRef: {
value: items[responsibilityCenters.ttl_tax_adjustment?.accountitem]
},
TaxCodeRef: {
value: QboTaxId
},
Qty: 1
}
});
} else {
InvoiceLineAdd.push({
ItemRef: {
FullName: responsibilityCenters.ttl_tax_adjustment?.accountitem
},
Desc: "Tax Adjustment",
Quantity: 1,
Amount: Dinero(jobs_by_pk.job_totals.totals?.ttl_tax_adjustment).toFormat(DineroQbFormat),
SalesTaxCodeRef: InstanceManager({
imex: {
FullName: "E"
},
rome: {
FullName: bodyshop.md_responsibility_centers.taxes.itemexemptcode || "NON"
}
})
});
}
}
if (!qbo && InvoiceLineAdd.length === 0) { if (!qbo && InvoiceLineAdd.length === 0) {
//Handle the scenario where there is a $0 sale invoice. //Handle the scenario where there is a $0 sale invoice.
InvoiceLineAdd.push({ InvoiceLineAdd.push({

View File

@@ -352,6 +352,7 @@ function calculateAllocations(connectionData, job) {
// console.log("NO MASH ACCOUNT FOUND!!"); // console.log("NO MASH ACCOUNT FOUND!!");
} }
} }
if (InstanceManager({ rome: true })) { if (InstanceManager({ rome: true })) {
//profile level adjustments for parts //profile level adjustments for parts
Object.keys(job.job_totals.parts.adjustments).forEach((key) => { Object.keys(job.job_totals.parts.adjustments).forEach((key) => {
@@ -427,6 +428,41 @@ function calculateAllocations(connectionData, job) {
} else { } else {
return { ...taxAllocations[key], tax: key }; return { ...taxAllocations[key], tax: key };
} }
}) }),
...(job.job_totals.totals.ttl_adjustment
? [
{
center: "SUB ADJ",
sale: Dinero(job.job_totals.totals.ttl_adjustment),
cost: Dinero(),
profitCenter: {
name: "SUB ADJ",
accountdesc: "SUB ADJ",
accountitem: "SUB ADJ",
accountname: "SUB ADJ",
dms_acctnumber: bodyshop.md_responsibility_centers.ttl_adjustment.dms_acctnumber
},
costCenter: {}
}
]
: []),
...(job.job_totals.totals.ttl_tax_adjustment
? [
{
center: "TAX ADJ",
sale: Dinero(job.job_totals.totals.ttl_tax_adjustment),
cost: Dinero(),
profitCenter: {
name: "TAX ADJ",
accountdesc: "TAX ADJ",
accountitem: "TAX ADJ",
accountname: "TAX ADJ",
dms_acctnumber: bodyshop.md_responsibility_centers.ttl_tax_adjustment.dms_acctnumber
},
costCenter: {}
}
]
: [])
]; ];
} }

View File

@@ -25,15 +25,15 @@ const ftpSetup = {
port: process.env.AUTOHOUSE_PORT, port: process.env.AUTOHOUSE_PORT,
username: process.env.AUTOHOUSE_USER, username: process.env.AUTOHOUSE_USER,
password: process.env.AUTOHOUSE_PASSWORD, password: process.env.AUTOHOUSE_PASSWORD,
debug: (message, ...data) => logger.log(message, "DEBUG", "api", null, data), debug:
process.env.NODE_ENV !== "production"
? (message, ...data) => logger.log(message, "DEBUG", "api", null, data)
: () => {},
algorithms: { algorithms: {
serverHostKey: ["ssh-rsa", "ssh-dss", "rsa-sha2-256", "rsa-sha2-512", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"] serverHostKey: ["ssh-rsa", "ssh-dss", "rsa-sha2-256", "rsa-sha2-512", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"]
} }
}; };
const allxmlsToUpload = [];
const allErrors = [];
exports.default = async (req, res) => { exports.default = async (req, res) => {
// Only process if in production environment. // Only process if in production environment.
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
@@ -55,12 +55,13 @@ exports.default = async (req, res) => {
try { try {
logger.log("autohouse-start", "DEBUG", "api", null, null); logger.log("autohouse-start", "DEBUG", "api", null, null);
const allXMLResults = [];
const allErrors = [];
const { bodyshops } = await client.request(queries.GET_AUTOHOUSE_SHOPS); //Query for the List of Bodyshop Clients. const { bodyshops } = await client.request(queries.GET_AUTOHOUSE_SHOPS); //Query for the List of Bodyshop Clients.
const specificShopIds = req.body.bodyshopIds; // ['uuid]; const specificShopIds = req.body.bodyshopIds; // ['uuid];
const { start, end, skipUpload } = req.body; //YYYY-MM-DD const { start, end, skipUpload } = req.body; //YYYY-MM-DD
const batchSize = 10;
const shopsToProcess = const shopsToProcess =
specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops; specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops;
logger.log("autohouse-shopsToProcess-generated", "DEBUG", "api", null, null); logger.log("autohouse-shopsToProcess-generated", "DEBUG", "api", null, null);
@@ -69,27 +70,18 @@ exports.default = async (req, res) => {
logger.log("autohouse-shopsToProcess-empty", "DEBUG", "api", null, null); logger.log("autohouse-shopsToProcess-empty", "DEBUG", "api", null, null);
return; return;
} }
const batchPromises = [];
for (let i = 0; i < shopsToProcess.length; i += batchSize) {
const batch = shopsToProcess.slice(i, i + batchSize);
const batchPromise = (async () => {
await processBatch(batch, start, end);
if (skipUpload) { await processShopData(shopsToProcess, start, end, skipUpload, allXMLResults, allErrors);
for (const xmlObj of allxmlsToUpload) {
fs.writeFileSync(`./logs/${xmlObj.filename}`, xmlObj.xml);
}
} else {
await uploadViaSFTP(allxmlsToUpload);
}
})();
batchPromises.push(batchPromise);
}
await Promise.all(batchPromises);
await sendServerEmail({ await sendServerEmail({
subject: `Autohouse Report ${moment().format("MM-DD-YY")}`, subject: `Autohouse Report ${moment().format("MM-DD-YY")}`,
text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify( text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify(
allxmlsToUpload.map((x) => ({ filename: x.filename, count: x.count, result: x.result })), allXMLResults.map((x) => ({
imexshopid: x.imexshopid,
filename: x.filename,
count: x.count,
result: x.result
})),
null, null,
2 2
)}` )}`
@@ -101,8 +93,8 @@ exports.default = async (req, res) => {
} }
}; };
async function processBatch(batch, start, end) { async function processShopData(shopsToProcess, start, end, skipUpload, allXMLResults, allErrors) {
for (const bodyshop of batch) { for (const bodyshop of shopsToProcess) {
const erroredJobs = []; const erroredJobs = [];
try { try {
logger.log("autohouse-start-shop-extract", "DEBUG", "api", bodyshop.id, { logger.log("autohouse-start-shop-extract", "DEBUG", "api", bodyshop.id, {
@@ -132,12 +124,27 @@ async function processBatch(batch, start, end) {
}); });
} }
const ret = builder.create({}, autoHouseObject).end({ allowEmptyTags: true }); const xmlObj = {
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
xml: builder.create({}, autoHouseObject).end({ allowEmptyTags: true }),
filename: `IM_${bodyshop.autohouseid}_${moment().format("DDMMYYYY_HHMMss")}.xml`,
count: autoHouseObject.AutoHouseExport.RepairOrder.length
};
allxmlsToUpload.push({ if (skipUpload) {
count: autoHouseObject.AutoHouseExport.RepairOrder.length, fs.writeFileSync(`./logs/${xmlObj.filename}`, xmlObj.xml);
xml: ret, } else {
filename: `IM_${bodyshop.autohouseid}_${moment().format("DDMMYYYY_HHMMss")}.xml` await uploadViaSFTP(xmlObj);
}
allXMLResults.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
autohouseid: bodyshop.autohouseid,
count: xmlObj.count,
filename: xmlObj.filename,
result: xmlObj.result
}); });
logger.log("autohouse-end-shop-extract", "DEBUG", "api", bodyshop.id, { logger.log("autohouse-end-shop-extract", "DEBUG", "api", bodyshop.id, {
@@ -169,33 +176,35 @@ async function processBatch(batch, start, end) {
} }
} }
async function uploadViaSFTP(allxmlsToUpload) { async function uploadViaSFTP(xmlObj) {
const sftp = new Client(); const sftp = new Client();
sftp.on("error", (errors) => sftp.on("error", (errors) =>
logger.log("autohouse-sftp-connection-error", "ERROR", "api", null, { error: errors.message, stack: errors.stack }) logger.log("autohouse-sftp-connection-error", "ERROR", "api", xmlObj.bodyshopid, {
error: errors.message,
stack: errors.stack
})
); );
try { try {
//Connect to the FTP and upload all. //Connect to the FTP and upload all.
await sftp.connect(ftpSetup); await sftp.connect(ftpSetup);
for (const xmlObj of allxmlsToUpload) { try {
try { xmlObj.result = await sftp.put(Buffer.from(xmlObj.xml), `${xmlObj.filename}`);
xmlObj.result = await sftp.put(Buffer.from(xmlObj.xml), `${xmlObj.filename}`); logger.log("autohouse-sftp-upload", "DEBUG", "api", xmlObj.bodyshopid, {
logger.log("autohouse-sftp-upload", "DEBUG", "api", null, { imexshopid: xmlObj.imexshopid,
filename: xmlObj.filename, filename: xmlObj.filename,
result: xmlObj.result result: xmlObj.result
}); });
} catch (error) { } catch (error) {
logger.log("autohouse-sftp-upload-error", "ERROR", "api", null, { logger.log("autohouse-sftp-upload-error", "ERROR", "api", xmlObj.bodyshopid, {
filename: xmlObj.filename, filename: xmlObj.filename,
error: error.message, error: error.message,
stack: error.stack stack: error.stack
}); });
throw error; throw error;
}
} }
} catch (error) { } catch (error) {
logger.log("autohouse-sftp-error", "ERROR", "api", null, { error: error.message, stack: error.stack }); logger.log("autohouse-sftp-error", "ERROR", "api", xmlObj.bodyshopid, { error: error.message, stack: error.stack });
throw error; throw error;
} finally { } finally {
sftp.end(); sftp.end();

View File

@@ -17,15 +17,15 @@ const ftpSetup = {
port: process.env.CHATTER_PORT, port: process.env.CHATTER_PORT,
username: process.env.CHATTER_USER, username: process.env.CHATTER_USER,
privateKey: null, privateKey: null,
debug: (message, ...data) => logger.log(message, "DEBUG", "api", null, data), debug:
process.env.NODE_ENV !== "production"
? (message, ...data) => logger.log(message, "DEBUG", "api", null, data)
: () => {},
algorithms: { algorithms: {
serverHostKey: ["ssh-rsa", "ssh-dss", "rsa-sha2-256", "rsa-sha2-512", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"] serverHostKey: ["ssh-rsa", "ssh-dss", "rsa-sha2-256", "rsa-sha2-512", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"]
} }
}; };
const allcsvsToUpload = [];
const allErrors = [];
exports.default = async (req, res) => { exports.default = async (req, res) => {
// Only process if in production environment. // Only process if in production environment.
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
@@ -47,12 +47,13 @@ exports.default = async (req, res) => {
try { try {
logger.log("chatter-start", "DEBUG", "api", null, null); logger.log("chatter-start", "DEBUG", "api", null, null);
const allChatterObjects = [];
const allErrors = [];
const { bodyshops } = await client.request(queries.GET_CHATTER_SHOPS); //Query for the List of Bodyshop Clients. const { bodyshops } = await client.request(queries.GET_CHATTER_SHOPS); //Query for the List of Bodyshop Clients.
const specificShopIds = req.body.bodyshopIds; // ['uuid]; const specificShopIds = req.body.bodyshopIds; // ['uuid];
const { start, end, skipUpload } = req.body; //YYYY-MM-DD const { start, end, skipUpload } = req.body; //YYYY-MM-DD
const batchSize = 10;
const shopsToProcess = const shopsToProcess =
specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops; specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops;
logger.log("chatter-shopsToProcess-generated", "DEBUG", "api", null, null); logger.log("chatter-shopsToProcess-generated", "DEBUG", "api", null, null);
@@ -62,29 +63,24 @@ exports.default = async (req, res) => {
return; return;
} }
const batchPromises = []; await processBatch(shopsToProcess, start, end, allChatterObjects, allErrors);
for (let i = 0; i < shopsToProcess.length; i += batchSize) {
const batch = shopsToProcess.slice(i, i + batchSize); const csvToUpload = {
const batchPromise = (async () => { count: allChatterObjects.length,
await processBatch(batch, start, end); csv: converter.json2csv(allChatterObjects, { emptyFieldValue: "" }),
if (skipUpload) { filename: `IMEX_ONLINE_solicitation_${moment().format("YYYYMMDD")}.csv`
for (const csvObj of allcsvsToUpload) { };
await fs.promises.writeFile(`./logs/${csvObj.filename}`, csvObj.csv);
} if (skipUpload) {
} else { await fs.promises.writeFile(`./logs/${csvToUpload.filename}`, csvToUpload.csv);
await uploadViaSFTP(allcsvsToUpload); } else {
} await uploadViaSFTP(csvToUpload);
})();
batchPromises.push(batchPromise);
} }
await Promise.all(batchPromises);
await sendServerEmail({ await sendServerEmail({
subject: `Chatter Report ${moment().format("MM-DD-YY")}`, subject: `Chatter Report ${moment().format("MM-DD-YY")}`,
text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify( text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\n
allcsvsToUpload.map((x) => ({ filename: x.filename, count: x.count, result: x.result })), Uploaded:\n${JSON.stringify({ filename: csvToUpload.filename, count: csvToUpload.count, result: csvToUpload.result }, null, 2)}`
null,
2
)}`
}); });
logger.log("chatter-end", "DEBUG", "api", null, null); logger.log("chatter-end", "DEBUG", "api", null, null);
@@ -93,8 +89,8 @@ exports.default = async (req, res) => {
} }
}; };
async function processBatch(batch, start, end) { async function processBatch(shopsToProcess, start, end, allChatterObjects, allErrors) {
for (const bodyshop of batch) { for (const bodyshop of shopsToProcess) {
try { try {
logger.log("chatter-start-shop-extract", "DEBUG", "api", bodyshop.id, { logger.log("chatter-start-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname shopname: bodyshop.shopname
@@ -113,18 +109,11 @@ async function processBatch(batch, start, end) {
lastname: j.ownr_co_nm ? j.ownr_co_nm : j.ownr_ln, lastname: j.ownr_co_nm ? j.ownr_co_nm : j.ownr_ln,
transaction_id: j.ro_number, transaction_id: j.ro_number,
email: j.ownr_ea, email: j.ownr_ea,
phone_number: j.ownr_ph1 phone_number: j.ownr_ph1,
transaction_time: (j.actual_delivery && moment(j.actual_delivery).tz(bodyshop.timezone).format("YYYYMMDD-HHmm")) || ""
}; };
}); });
allChatterObjects.push(...chatterObject);
const ret = converter.json2csv(chatterObject, { emptyFieldValue: "" });
allcsvsToUpload.push({
count: chatterObject.length,
csv: ret,
filename: `${bodyshop.shopname}_solicitation_${moment().format("YYYYMMDD")}.csv`
});
logger.log("chatter-end-shop-extract", "DEBUG", "api", bodyshop.id, { logger.log("chatter-end-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname shopname: bodyshop.shopname
}); });
@@ -166,7 +155,7 @@ async function getPrivateKey() {
} }
} }
async function uploadViaSFTP(allcsvsToUpload) { async function uploadViaSFTP(csvToUpload) {
const sftp = new Client(); const sftp = new Client();
sftp.on("error", (errors) => sftp.on("error", (errors) =>
logger.log("chatter-sftp-connection-error", "ERROR", "api", null, { error: errors.message, stack: errors.stack }) logger.log("chatter-sftp-connection-error", "ERROR", "api", null, { error: errors.message, stack: errors.stack })
@@ -178,21 +167,19 @@ async function uploadViaSFTP(allcsvsToUpload) {
//Connect to the FTP and upload all. //Connect to the FTP and upload all.
await sftp.connect({ ...ftpSetup, privateKey }); await sftp.connect({ ...ftpSetup, privateKey });
for (const csvObj of allcsvsToUpload) { try {
try { csvToUpload.result = await sftp.put(Buffer.from(csvToUpload.csv), `${csvToUpload.filename}`);
csvObj.result = await sftp.put(Buffer.from(csvObj.csv), `${csvObj.filename}`); logger.log("chatter-sftp-upload", "DEBUG", "api", null, {
logger.log("chatter-sftp-upload", "DEBUG", "api", null, { filename: csvToUpload.filename,
filename: csvObj.filename, result: csvToUpload.result
result: csvObj.result });
}); } catch (error) {
} catch (error) { logger.log("chatter-sftp-upload-error", "ERROR", "api", null, {
logger.log("chatter-sftp-upload-error", "ERROR", "api", null, { filename: csvToUpload.filename,
filename: csvObj.filename, error: csvToUpload.message,
error: error.message, stack: csvToUpload.stack
stack: error.stack });
}); throw error;
throw error;
}
} }
} catch (error) { } catch (error) {
logger.log("chatter-sftp-error", "ERROR", "api", null, { error: error.message, stack: error.stack }); logger.log("chatter-sftp-error", "ERROR", "api", null, { error: error.message, stack: error.stack });

View File

@@ -24,15 +24,15 @@ const ftpSetup = {
port: process.env.CLAIMSCORP_PORT, port: process.env.CLAIMSCORP_PORT,
username: process.env.CLAIMSCORP_USER, username: process.env.CLAIMSCORP_USER,
password: process.env.CLAIMSCORP_PASSWORD, password: process.env.CLAIMSCORP_PASSWORD,
debug: (message, ...data) => logger.log(message, "DEBUG", "api", null, data), debug:
process.env.NODE_ENV !== "production"
? (message, ...data) => logger.log(message, "DEBUG", "api", null, data)
: () => {},
algorithms: { algorithms: {
serverHostKey: ["ssh-rsa", "ssh-dss", "rsa-sha2-256", "rsa-sha2-512", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"] serverHostKey: ["ssh-rsa", "ssh-dss", "rsa-sha2-256", "rsa-sha2-512", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"]
} }
}; };
const allxmlsToUpload = [];
const allErrors = [];
exports.default = async (req, res) => { exports.default = async (req, res) => {
// Only process if in production environment. // Only process if in production environment.
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
@@ -54,12 +54,13 @@ exports.default = async (req, res) => {
try { try {
logger.log("claimscorp-start", "DEBUG", "api", null, null); logger.log("claimscorp-start", "DEBUG", "api", null, null);
const allXMLResults = [];
const allErrors = [];
const { bodyshops } = await client.request(queries.GET_CLAIMSCORP_SHOPS); //Query for the List of Bodyshop Clients. const { bodyshops } = await client.request(queries.GET_CLAIMSCORP_SHOPS); //Query for the List of Bodyshop Clients.
const specificShopIds = req.body.bodyshopIds; // ['uuid]; const specificShopIds = req.body.bodyshopIds; // ['uuid];
const { start, end, skipUpload } = req.body; //YYYY-MM-DD const { start, end, skipUpload } = req.body; //YYYY-MM-DD
const batchSize = 10;
const shopsToProcess = const shopsToProcess =
specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops; specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops;
logger.log("claimscorp-shopsToProcess-generated", "DEBUG", "api", null, null); logger.log("claimscorp-shopsToProcess-generated", "DEBUG", "api", null, null);
@@ -68,27 +69,18 @@ exports.default = async (req, res) => {
logger.log("claimscorp-shopsToProcess-empty", "DEBUG", "api", null, null); logger.log("claimscorp-shopsToProcess-empty", "DEBUG", "api", null, null);
return; return;
} }
const batchPromises = [];
for (let i = 0; i < shopsToProcess.length; i += batchSize) {
const batch = shopsToProcess.slice(i, i + batchSize);
const batchPromise = (async () => {
await processBatch(batch, start, end);
if (skipUpload) { await processShopData(shopsToProcess, start, end, skipUpload, allXMLResults, allErrors);
for (const xmlObj of allxmlsToUpload) {
fs.writeFileSync(`./logs/${xmlObj.filename}`, xmlObj.xml);
}
} else {
await uploadViaSFTP(allxmlsToUpload);
}
})();
batchPromises.push(batchPromise);
}
await Promise.all(batchPromises);
await sendServerEmail({ await sendServerEmail({
subject: `ClaimsCorp Report ${moment().format("MM-DD-YY")}`, subject: `ClaimsCorp Report ${moment().format("MM-DD-YY")}`,
text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify( text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify(
allxmlsToUpload.map((x) => ({ filename: x.filename, count: x.count, result: x.result })), allXMLResults.map((x) => ({
imexshopid: x.imexshopid,
filename: x.filename,
count: x.count,
result: x.result
})),
null, null,
2 2
)}` )}`
@@ -100,8 +92,8 @@ exports.default = async (req, res) => {
} }
}; };
async function processBatch(batch, start, end) { async function processShopData(shopsToProcess, start, end, skipUpload, allXMLResults, allErrors) {
for (const bodyshop of batch) { for (const bodyshop of shopsToProcess) {
const erroredJobs = []; const erroredJobs = [];
try { try {
logger.log("claimscorp-start-shop-extract", "DEBUG", "api", bodyshop.id, { logger.log("claimscorp-start-shop-extract", "DEBUG", "api", bodyshop.id, {
@@ -135,12 +127,27 @@ async function processBatch(batch, start, end) {
}); });
} }
const ret = builder.create({}, claimsCorpObject).end({ allowEmptyTags: true }); const xmlObj = {
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
xml: builder.create({}, claimsCorpObject).end({ allowEmptyTags: true }),
filename: `${bodyshop.claimscorpid}-${moment().format("YYYYMMDDTHHMMss")}.xml`,
count: claimsCorpObject.DataFeed.ShopInfo.RO.length
};
allxmlsToUpload.push({ if (skipUpload) {
count: claimsCorpObject.DataFeed.ShopInfo.RO.length, fs.writeFileSync(`./logs/${xmlObj.filename}`, xmlObj.xml);
xml: ret, } else {
filename: `${bodyshop.claimscorpid}-${moment().format("YYYYMMDDTHHMMss")}.xml` await uploadViaSFTP(xmlObj);
}
allXMLResults.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
claimscorpid: bodyshop.claimscorpid,
count: xmlObj.count,
filename: xmlObj.filename,
result: xmlObj.result
}); });
logger.log("claimscorp-end-shop-extract", "DEBUG", "api", bodyshop.id, { logger.log("claimscorp-end-shop-extract", "DEBUG", "api", bodyshop.id, {
@@ -172,33 +179,38 @@ async function processBatch(batch, start, end) {
} }
} }
async function uploadViaSFTP(allxmlsToUpload) { async function uploadViaSFTP(xmlObj) {
const sftp = new Client(); const sftp = new Client();
sftp.on("error", (errors) => sftp.on("error", (errors) =>
logger.log("claimscorp-sftp-connection-error", "ERROR", "api", null, { error: errors.message, stack: errors.stack }) logger.log("claimscorp-sftp-connection-error", "ERROR", "api", xmlObj.bodyshopid, {
error: errors.message,
stack: errors.stack
})
); );
try { try {
//Connect to the FTP and upload all. //Connect to the FTP and upload all.
await sftp.connect(ftpSetup); await sftp.connect(ftpSetup);
for (const xmlObj of allxmlsToUpload) { try {
try { xmlObj.result = await sftp.put(Buffer.from(xmlObj.xml), `${xmlObj.filename}`);
xmlObj.result = await sftp.put(Buffer.from(xmlObj.xml), `${xmlObj.filename}`); logger.log("claimscorp-sftp-upload", "DEBUG", "api", xmlObj.bodyshopid, {
logger.log("claimscorp-sftp-upload", "DEBUG", "api", null, { imexshopid: xmlObj.imexshopid,
filename: xmlObj.filename, filename: xmlObj.filename,
result: xmlObj.result result: xmlObj.result
}); });
} catch (error) { } catch (error) {
logger.log("claimscorp-sftp-upload-error", "ERROR", "api", null, { logger.log("claimscorp-sftp-upload-error", "ERROR", "api", xmlObj.bodyshopid, {
filename: xmlObj.filename, filename: xmlObj.filename,
error: error.message, error: error.message,
stack: error.stack stack: error.stack
}); });
throw error; throw error;
}
} }
} catch (error) { } catch (error) {
logger.log("claimscorp-sftp-error", "ERROR", "api", null, { error: error.message, stack: error.stack }); logger.log("claimscorp-sftp-error", "ERROR", "api", xmlObj.bodyshopid, {
error: error.message,
stack: error.stack
});
throw error; throw error;
} finally { } finally {
sftp.end(); sftp.end();

View File

@@ -23,15 +23,15 @@ const ftpSetup = {
port: process.env.KAIZEN_PORT, port: process.env.KAIZEN_PORT,
username: process.env.KAIZEN_USER, username: process.env.KAIZEN_USER,
password: process.env.KAIZEN_PASSWORD, password: process.env.KAIZEN_PASSWORD,
debug: (message, ...data) => logger.log(message, "DEBUG", "api", null, data), debug:
process.env.NODE_ENV !== "production"
? (message, ...data) => logger.log(message, "DEBUG", "api", null, data)
: () => {},
algorithms: { algorithms: {
serverHostKey: ["ssh-rsa", "ssh-dss", "rsa-sha2-256", "rsa-sha2-512", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"] serverHostKey: ["ssh-rsa", "ssh-dss", "rsa-sha2-256", "rsa-sha2-512", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"]
} }
}; };
const allxmlsToUpload = [];
const allErrors = [];
exports.default = async (req, res) => { exports.default = async (req, res) => {
// Only process if in production environment. // Only process if in production environment.
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
@@ -53,12 +53,13 @@ exports.default = async (req, res) => {
try { try {
logger.log("kaizen-start", "DEBUG", "api", null, null); logger.log("kaizen-start", "DEBUG", "api", null, null);
const allXMLResults = [];
const allErrors = [];
const { bodyshops } = await client.request(queries.GET_KAIZEN_SHOPS, { imexshopid: kaizenShopsIDs }); //Query for the List of Bodyshop Clients. const { bodyshops } = await client.request(queries.GET_KAIZEN_SHOPS, { imexshopid: kaizenShopsIDs }); //Query for the List of Bodyshop Clients.
const specificShopIds = req.body.bodyshopIds; // ['uuid]; const specificShopIds = req.body.bodyshopIds; // ['uuid];
const { start, end, skipUpload } = req.body; //YYYY-MM-DD const { start, end, skipUpload } = req.body; //YYYY-MM-DD
const batchSize = 10;
const shopsToProcess = const shopsToProcess =
specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops; specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops;
logger.log("kaizen-shopsToProcess-generated", "DEBUG", "api", null, null); logger.log("kaizen-shopsToProcess-generated", "DEBUG", "api", null, null);
@@ -67,27 +68,18 @@ exports.default = async (req, res) => {
logger.log("kaizen-shopsToProcess-empty", "DEBUG", "api", null, null); logger.log("kaizen-shopsToProcess-empty", "DEBUG", "api", null, null);
return; return;
} }
const batchPromises = [];
for (let i = 0; i < shopsToProcess.length; i += batchSize) {
const batch = shopsToProcess.slice(i, i + batchSize);
const batchPromise = (async () => {
await processBatch(batch, start, end);
if (skipUpload) { await processShopData(shopsToProcess, start, end, skipUpload, allXMLResults, allErrors);
for (const xmlObj of allxmlsToUpload) {
fs.writeFileSync(`./logs/${xmlObj.filename}`, xmlObj.xml);
}
} else {
await uploadViaSFTP(allxmlsToUpload);
}
})();
batchPromises.push(batchPromise);
}
await Promise.all(batchPromises);
await sendServerEmail({ await sendServerEmail({
subject: `Kaizen Report ${moment().format("MM-DD-YY")}`, subject: `Kaizen Report ${moment().format("MM-DD-YY")}`,
text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify( text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify(
allxmlsToUpload.map((x) => ({ filename: x.filename, count: x.count, result: x.result })), allXMLResults.map((x) => ({
imexshopid: x.imexshopid,
filename: x.filename,
count: x.count,
result: x.result
})),
null, null,
2 2
)}` )}`
@@ -99,8 +91,8 @@ exports.default = async (req, res) => {
} }
}; };
async function processBatch(batch, start, end) { async function processShopData(shopsToProcess, start, end, skipUpload, allXMLResults, allErrors) {
for (const bodyshop of batch) { for (const bodyshop of shopsToProcess) {
const erroredJobs = []; const erroredJobs = [];
try { try {
logger.log("kaizen-start-shop-extract", "DEBUG", "api", bodyshop.id, { logger.log("kaizen-start-shop-extract", "DEBUG", "api", bodyshop.id, {
@@ -133,12 +125,26 @@ async function processBatch(batch, start, end) {
}); });
} }
const ret = builder.create({}, kaizenObject).end({ allowEmptyTags: true }); const xmlObj = {
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
xml: builder.create({}, kaizenObject).end({ allowEmptyTags: true }),
filename: `${bodyshop.shopname}-${moment().format("YYYYMMDDTHHMMss")}.xml`,
count: kaizenObject.DataFeed.ShopInfo.Jobs.length
};
allxmlsToUpload.push({ if (skipUpload) {
count: kaizenObject.DataFeed.ShopInfo.Jobs.length, fs.writeFileSync(`./logs/${xmlObj.filename}`, xmlObj.xml);
xml: ret, } else {
filename: `${bodyshop.shopname}-${moment().format("YYYYMMDDTHHMMss")}.xml` await uploadViaSFTP(xmlObj);
}
allXMLResults.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
count: xmlObj.count,
filename: xmlObj.filename,
result: xmlObj.result
}); });
logger.log("kaizen-end-shop-extract", "DEBUG", "api", bodyshop.id, { logger.log("kaizen-end-shop-extract", "DEBUG", "api", bodyshop.id, {
@@ -170,33 +176,35 @@ async function processBatch(batch, start, end) {
} }
} }
async function uploadViaSFTP(allxmlsToUpload) { async function uploadViaSFTP(xmlObj) {
const sftp = new Client(); const sftp = new Client();
sftp.on("error", (errors) => sftp.on("error", (errors) =>
logger.log("kaizen-sftp-connection-error", "ERROR", "api", null, { error: errors.message, stack: errors.stack }) logger.log("kaizen-sftp-connection-error", "ERROR", "api", xmlObj.bodyshopid, {
error: errors.message,
stack: errors.stack
})
); );
try { try {
//Connect to the FTP and upload all. //Connect to the FTP and upload all.
await sftp.connect(ftpSetup); await sftp.connect(ftpSetup);
for (const xmlObj of allxmlsToUpload) { try {
try { xmlObj.result = await sftp.put(Buffer.from(xmlObj.xml), `${xmlObj.filename}`);
xmlObj.result = await sftp.put(Buffer.from(xmlObj.xml), `${xmlObj.filename}`); logger.log("kaizen-sftp-upload", "DEBUG", "api", xmlObj.bodyshopid, {
logger.log("kaizen-sftp-upload", "DEBUG", "api", null, { imexshopid: xmlObj.imexshopid,
filename: xmlObj.filename, filename: xmlObj.filename,
result: xmlObj.result result: xmlObj.result
}); });
} catch (error) { } catch (error) {
logger.log("kaizen-sftp-upload-error", "ERROR", "api", null, { logger.log("kaizen-sftp-upload-error", "ERROR", "api", xmlObj.bodyshopid, {
filename: xmlObj.filename, filename: xmlObj.filename,
error: error.message, error: error.message,
stack: error.stack stack: error.stack
}); });
throw error; throw error;
}
} }
} catch (error) { } catch (error) {
logger.log("kaizen-sftp-error", "ERROR", "api", null, { error: error.message, stack: error.stack }); logger.log("kaizen-sftp-error", "ERROR", "api", xmlObj.bodyshopid, { error: error.message, stack: error.stack });
throw error; throw error;
} finally { } finally {
sftp.end(); sftp.end();

View File

@@ -4,6 +4,7 @@ query FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID($mssid: String!, $phone: String!) {
id id
conversations(where: { phone_num: { _eq: $phone } }) { conversations(where: { phone_num: { _eq: $phone } }) {
id id
created_at
} }
} }
}`; }`;
@@ -87,6 +88,21 @@ mutation RECEIVE_MESSAGE($msg: [messages_insert_input!]!) {
updated_at updated_at
unreadcnt unreadcnt
phone_num phone_num
label
job_conversations {
job {
id
ro_number
ownr_fn
ownr_ln
ownr_co_nm
}
}
messages_aggregate (where: { read: { _eq: false }, isoutbound: { _eq: false } }){
aggregate {
count
}
}
} }
conversationid conversationid
created_at created_at
@@ -116,6 +132,7 @@ mutation INSERT_MESSAGE($msg: [messages_insert_input!]!, $conversationid: uuid!)
id id
archived archived
bodyshop { bodyshop {
id
imexshopid imexshopid
} }
created_at created_at
@@ -144,6 +161,11 @@ mutation UPDATE_MESSAGE($msid: String!, $fields: messages_set_input!) {
update_messages(where: { msid: { _eq: $msid } }, _set: $fields) { update_messages(where: { msid: { _eq: $msid } }, _set: $fields) {
returning { returning {
id id
status
conversationid
conversation{
bodyshopid
}
} }
} }
}`; }`;
@@ -840,6 +862,7 @@ exports.CHATTER_QUERY = `query CHATTER_EXPORT($start: timestamptz, $bodyshopid:
timezone timezone
} }
jobs(where: {_and: [{converted: {_eq: true}}, {actual_delivery: {_gt: $start}}, {actual_delivery: {_lte: $end}}, {shopid: {_eq: $bodyshopid}}, {_or: [{ownr_ph1: {_is_null: false}}, {ownr_ea: {_is_null: false}}]}]}) { jobs(where: {_and: [{converted: {_eq: true}}, {actual_delivery: {_gt: $start}}, {actual_delivery: {_lte: $end}}, {shopid: {_eq: $bodyshopid}}, {_or: [{ownr_ph1: {_is_null: false}}, {ownr_ea: {_is_null: false}}]}]}) {
actual_delivery
id id
created_at created_at
ro_number ro_number
@@ -1365,6 +1388,7 @@ exports.GET_JOB_BY_PK = `query GET_JOB_BY_PK($id: uuid!) {
cieca_pfl cieca_pfl
cieca_pft cieca_pft
cieca_pfo cieca_pfo
cieca_ttl
vehicle { vehicle {
id id
notes notes
@@ -1494,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. //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) { jobs_by_pk(id: $id) {
ro_number ro_number
clm_total clm_total
@@ -2544,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

@@ -14,7 +14,7 @@ require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
}); });
const domain = process.env.NODE_ENV ? "secure" : "test"; const domain = process.env.NODE_ENV ? "secure" : "secure";
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager"); const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
const { InstanceRegion } = require("../utils/instanceMgr"); const { InstanceRegion } = require("../utils/instanceMgr");
@@ -149,6 +149,58 @@ exports.generate_payment_url = async (req, res) => {
} }
}; };
//Reference: https://intellipay.com/dist/webapi26.html#operation/fee
exports.checkfee = async (req, res) => {
// Requires amount, bodyshop.imexshopid, and state? to get data.
logger.log("intellipay-fee-check", "DEBUG", req.user?.email, null, null);
//If there's no amount, there can't be a fee. Skip the call.
if (!req.body.amount || req.body.amount <= 0) {
res.json({ fee: 0 });
return;
}
const shopCredentials = await getShopCredentials(req.body.bodyshop);
try {
const options = {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
//TODO: Move these to environment variables/database.
data: qs.stringify(
{
method: "fee",
...shopCredentials,
amount: req.body.amount,
paymenttype: `CC`,
cardnum: "4111111111111111", //Not needed per documentation, but incorrect values come back without it.
state:
req.body.bodyshop?.state && req.body.bodyshop.state?.length === 2
? req.body.bodyshop.state.toUpperCase()
: "ZZ" //Same as above
},
{ sort: false } //ColdFusion Query Strings depend on order. This preserves it.
),
url: `https://${domain}.cpteller.com/api/26/webapi.cfc`
};
const response = await axios(options);
if (response.data?.error) {
res.status(400).json({ error: response.data.error });
} else if (response.data < 0) {
res.json({ error: "Fee amount negative. Check API credentials & account configuration." });
} else {
res.json({ fee: response.data });
}
} catch (error) {
//console.log(error);
logger.log("intellipay-fee-check-error", "ERROR", req.user?.email, null, {
error: error.message
});
res.status(400).json({ error });
}
};
exports.postback = async (req, res) => { exports.postback = async (req, res) => {
try { try {
logger.log("intellipay-postback", "DEBUG", req.user?.email, null, req.body); logger.log("intellipay-postback", "DEBUG", req.user?.email, null, req.body);

View File

@@ -849,6 +849,41 @@ function GenerateCostingData(job) {
gppercent: formatGpPercent(0) gppercent: formatGpPercent(0)
}); });
} }
//Push adjustments to bottom line.
if (job.job_totals?.totals?.ttl_adjustment) {
//Add to totals.
const Adjustment = Dinero(job.job_totals.totals.ttl_adjustment); //Need to invert, since this is being assigned as a cost.
summaryData.totalAdditionalSales = summaryData.totalAdditionalSales.add(Adjustment);
summaryData.totalSales = summaryData.totalSales.add(Adjustment);
//Add to lines.
costCenterData.push({
id: "Adj",
cost_center: "Adjustment",
sale_labor: Dinero().toFormat(),
sale_labor_dinero: Dinero(),
sale_parts: Dinero().toFormat(),
sale_parts_dinero: Dinero(),
sale_additional: Adjustment.toFormat(),
sale_additional_dinero: Adjustment,
sale_sublet: Dinero(),
sale_sublet_dinero: Dinero(),
sales: Adjustment.toFormat(),
sales_dinero: Adjustment,
cost_parts: Dinero().toFormat(),
cost_parts_dinero: Dinero(),
cost_labor: Dinero().toFormat(), //Adjustment.toFormat(),
cost_labor_dinero: Dinero(), // Adjustment,
cost_additional: Dinero(),
cost_additional_dinero: Dinero(),
cost_sublet: Dinero(),
cost_sublet_dinero: Dinero(),
costs: Dinero().toFormat(),
costs_dinero: Dinero(),
gpdollars_dinero: Dinero(),
gpdollars: Dinero().toFormat(),
gppercent: formatGpPercent(0)
});
}
//Final summary data massaging. //Final summary data massaging.

View File

@@ -49,7 +49,7 @@ exports.totalsSsu = async function (req, res) {
} catch (error) { } catch (error) {
logger.log("job-totals-ssu-USA-error", "ERROR", req?.user?.email, id, { logger.log("job-totals-ssu-USA-error", "ERROR", req?.user?.email, id, {
jobid: id, jobid: id,
error error: error.message
}); });
res.status(503).send(); res.status(503).send();
} }
@@ -68,6 +68,45 @@ async function TotalsServerSide(req, res) {
ret.additional = CalculateAdditional(job); ret.additional = CalculateAdditional(job);
ret.totals = CalculateTaxesTotals(job, ret); ret.totals = CalculateTaxesTotals(job, ret);
// Sub total scrubbbing.
const emsTotal =
job.cieca_ttl.data.n_ttl_amt === job.cieca_ttl.data.g_ttl_amt //It looks like sometimes, gross and net are the same, but they shouldn't be.
? job.cieca_ttl.data.n_ttl_amt - job.cieca_ttl.data.g_tax
: job.cieca_ttl.data.g_ttl_amt - job.cieca_ttl.data.g_tax; //If they are, adjust the gross total down by the tax amount.
const ttlDifference = emsTotal - ret.totals.subtotal.getAmount() / 100;
if (Math.abs(ttlDifference) > 0.0) {
//If difference is greater than a pennny, we need to adjust it.
ret.totals.ttl_adjustment = Dinero({ amount: Math.round(ttlDifference * 100) });
ret.totals.subtotal = ret.totals.subtotal.add(ret.totals.ttl_adjustment);
ret.totals.total_repairs = ret.totals.total_repairs.add(ret.totals.ttl_adjustment);
ret.totals.net_repairs = ret.totals.net_repairs.add(ret.totals.ttl_adjustment);
logger.log("job-totals-USA-ttl-adj", "DEBUG", null, job.id, {
adjAmount: ttlDifference
});
}
//Taxes Scrubbing
const emsTaxTotal = job.cieca_ttl.data.g_tax;
const totalUsTaxes =
(ret.totals.us_sales_tax_breakdown.ty1Tax.getAmount() +
ret.totals.us_sales_tax_breakdown.ty2Tax.getAmount() +
ret.totals.us_sales_tax_breakdown.ty3Tax.getAmount() +
ret.totals.us_sales_tax_breakdown.ty4Tax.getAmount() +
ret.totals.us_sales_tax_breakdown.ty5Tax.getAmount()) /
100;
const ttlTaxDifference = emsTaxTotal - totalUsTaxes;
if (Math.abs(ttlTaxDifference) > 0.0) {
//If difference is greater than a pennny, we need to adjust it.
ret.totals.ttl_tax_adjustment = Dinero({ amount: Math.round(ttlTaxDifference * 100) });
ret.totals.total_repairs = ret.totals.total_repairs.add(ret.totals.ttl_tax_adjustment);
ret.totals.net_repairs = ret.totals.net_repairs.add(ret.totals.ttl_tax_adjustment);
logger.log("job-totals-USA-ttl-tax-adj", "DEBUG", null, job.id, {
adjAmount: ttlTaxDifference
});
}
return ret; return ret;
} catch (error) { } catch (error) {
logger.log("job-totals-ssu-USA-error", "ERROR", req.user?.email, job.id, { logger.log("job-totals-ssu-USA-error", "ERROR", req.user?.email, job.id, {
@@ -842,17 +881,21 @@ function CalculateTaxesTotals(job, otherTotals) {
} }
}); });
//Add towing and storage taxable amounts //Add towing and storage taxable amounts
const stlTowing = job.cieca_stl?.data.find((c) => c.ttl_typecd === "OTTW"); const stlTowing = job.cieca_stl?.data.find((c) => c.ttl_typecd === "OTTW" || c.ttl_type === "OTTW");
const stlStorage = job.cieca_stl?.data.find((c) => c.ttl_typecd === "OTST"); const stlStorage = job.cieca_stl?.data.find((c) => c.ttl_typecd === "OTST" || c.ttl_type === "OTST");
if (stlTowing) if (stlTowing)
taxableAmounts.TOW = Dinero({ taxableAmounts.TOW = taxableAmounts.TOW.add(
amount: Math.round(stlTowing.t_amt * 100) Dinero({
}); amount: Math.round(stlTowing.t_amt * 100)
})
);
if (stlStorage) if (stlStorage)
taxableAmounts.TOW = Dinero({ taxableAmounts.TOW = taxableAmounts.TOW.add(
amount: Math.round(stlStorage.t_amt * 100) (taxableAmounts.TOW = Dinero({
}); amount: Math.round(stlStorage.t_amt * 100)
}))
);
const pfp = job.parts_tax_rates; const pfp = job.parts_tax_rates;
@@ -959,7 +1002,7 @@ function CalculateTaxesTotals(job, otherTotals) {
} }
} }
} catch (error) { } catch (error) {
logger.log("job-totals-USA Key with issue", "error", null, null, { logger.log("job-totals-USA Key with issue", "error", null, job.id, {
key key
}); });
} }
@@ -989,7 +1032,7 @@ function CalculateTaxesTotals(job, otherTotals) {
for (let threshCounter = 1; threshCounter <= 5; threshCounter++) { for (let threshCounter = 1; threshCounter <= 5; threshCounter++) {
const thresholdAmount = parseFloat(job.cieca_pft[`ty${tyCounter}_thres${threshCounter}`]) || 0; const thresholdAmount = parseFloat(job.cieca_pft[`ty${tyCounter}_thres${threshCounter}`]) || 0;
const thresholdTaxRate = parseFloat(job.cieca_pft[`ty${tyCounter}_rate${threshCounter}`]) || 0; const thresholdTaxRate = parseFloat(job.cieca_pft[`ty${tyCounter}_rate${threshCounter}`]) || 0;
// console.log(taxTierKey, tyCounter, threshCounter, thresholdAmount, thresholdTaxRate);
let taxableAmountInThisThreshold; let taxableAmountInThisThreshold;
if ( if (
thresholdAmount === 9999.99 || thresholdAmount === 9999.99 ||
@@ -1013,11 +1056,8 @@ function CalculateTaxesTotals(job, otherTotals) {
taxableAmountInThisThreshold = Dinero({ taxableAmountInThisThreshold = Dinero({
amount: Math.round(thresholdAmount * 100) amount: Math.round(thresholdAmount * 100)
}); });
remainingTaxableAmounts[taxTierKey] = remainingTaxableAmounts[taxTierKey].subtract( remainingTaxableAmounts[taxTierKey] =
Dinero({ remainingTaxableAmounts[taxTierKey].subtract(taxableAmountInThisThreshold);
amount: Math.round(taxableAmountInThisThreshold * 100)
})
);
} }
} }
@@ -1026,8 +1066,8 @@ function CalculateTaxesTotals(job, otherTotals) {
totalTaxByTier[taxTierKey] = totalTaxByTier[taxTierKey].add(taxAmountToAdd); totalTaxByTier[taxTierKey] = totalTaxByTier[taxTierKey].add(taxAmountToAdd);
} }
} catch (error) { } catch (error) {
logger.log("job-totals-USA - PFP Calculation Error", "error", null, null, { logger.log("job-totals-USA - PFP Calculation Error", "error", null, job.id, {
error error: error.message
}); });
} }
}); });

View File

@@ -1,11 +1,12 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const { lightbox_credentials, payment_refund, generate_payment_url, postback } = require("../intellipay/intellipay"); const { lightbox_credentials, payment_refund, generate_payment_url, postback, checkfee } = require("../intellipay/intellipay");
router.post("/lightbox_credentials", validateFirebaseIdTokenMiddleware, lightbox_credentials); router.post("/lightbox_credentials", validateFirebaseIdTokenMiddleware, lightbox_credentials);
router.post("/payment_refund", validateFirebaseIdTokenMiddleware, payment_refund); router.post("/payment_refund", validateFirebaseIdTokenMiddleware, payment_refund);
router.post("/generate_payment_url", validateFirebaseIdTokenMiddleware, generate_payment_url); router.post("/generate_payment_url", validateFirebaseIdTokenMiddleware, generate_payment_url);
router.post("/checkfee", validateFirebaseIdTokenMiddleware, checkfee);
router.post("/postback", postback); router.post("/postback", postback);
module.exports = router; module.exports = router;

View File

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

View File

@@ -11,187 +11,162 @@ const logger = require("../utils/logger");
const InstanceManager = require("../utils/instanceMgr").default; const InstanceManager = require("../utils/instanceMgr").default;
exports.receive = async (req, res) => { exports.receive = async (req, res) => {
//Perform request validation const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
logger.log("sms-inbound", "DEBUG", "api", null, { const loggerData = {
msid: req.body.SmsMessageSid, msid: req.body.SmsMessageSid,
text: req.body.Body, text: req.body.Body,
image: !!req.body.MediaUrl0, image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body) image_path: generateMediaArray(req.body)
}); };
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, { 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, msid: req.body.SmsMessageSid,
text: req.body.Body, text: req.body.Body,
image: !!req.body.MediaUrl0, image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body), image_path: generateMediaArray(req.body),
type: "malformed-request" isoutbound: false,
}); userid: null // Add additional fields as necessary
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
});
let newMessage = { if (existingConversation) {
msid: req.body.SmsMessageSid, // Use the existing conversation
text: req.body.Body, conversationid = existingConversation.id;
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 || ""
};
const fcmresp = await admin.messaging().send({ // Unarchive the conversation if necessary
topic: `${message.conversation.bodyshop.imexshopid}-messaging`, if (existingConversation.archived) {
notification: { await client.request(queries.UNARCHIVE_CONVERSATION, {
title: InstanceManager({ id: conversationid,
imex: `ImEX Online Message - ${data.phone_num}`, archived: false
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);
}
} }
} catch (e1) { } else {
logger.log("sms-inbound-error", "ERROR", "api", null, { // Create a new conversation
msid: req.body.SmsMessageSid, const newConversationResponse = await client.request(queries.CREATE_CONVERSATION, {
text: req.body.Body, conversation: {
image: !!req.body.MediaUrl0, bodyshopid: bodyshop.id,
image_path: generateMediaArray(req.body), phone_num: phone(req.body.From).phoneNumber,
messagingServiceSid: req.body.MessagingServiceSid, archived: false
error: e1 }
}); });
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 generateMediaArray = (body) => {
const { NumMedia } = body; const { NumMedia } = body;
if (parseInt(NumMedia) > 0) { if (parseInt(NumMedia) > 0) {
//stuff
const ret = []; const ret = [];
for (var i = 0; i < parseInt(NumMedia); i++) { for (let i = 0; i < parseInt(NumMedia); i++) {
ret.push(body[`MediaUrl${i}`]); ret.push(body[`MediaUrl${i}`]);
} }
return ret; return ret;
@@ -199,3 +174,17 @@ const generateMediaArray = (body) => {
return null; 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 queries = require("../graphql-client/queries");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY); const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY);
const { admin } = require("../firebase/firebase-handler");
const gqlClient = require("../graphql-client/graphql-client").client; 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 { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body;
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
logger.log("sms-outbound", "DEBUG", req.user.email, null, { logger.log("sms-outbound", "DEBUG", req.user.email, null, {
messagingServiceSid: messagingServiceSid, messagingServiceSid,
to: phone(to).phoneNumber, to: phone(to).phoneNumber,
mediaUrl: selectedMedia.map((i) => i.src), mediaUrl: selectedMedia.map((i) => i.src),
text: body, text: body,
@@ -27,66 +29,10 @@ exports.send = (req, res) => {
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
}); });
if (!!to && !!messagingServiceSid && (!!body || !!selectedMedia.length > 0) && !!conversationid) { 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 {
logger.log("sms-outbound-error", "ERROR", req.user.email, null, { logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
type: "missing-parameters", type: "missing-parameters",
messagingServiceSid: messagingServiceSid, messagingServiceSid,
to: phone(to).phoneNumber, to: phone(to).phoneNumber,
text: body, text: body,
conversationid, conversationid,
@@ -96,5 +42,72 @@ exports.send = (req, res) => {
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
}); });
res.status(400).json({ success: false, message: "Missing required parameter(s)." }); 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 client = require("../graphql-client/graphql-client").client;
const queries = require("../graphql-client/queries"); const queries = require("../graphql-client/queries");
const { phone } = require("phone");
const logger = require("../utils/logger"); 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; const { SmsSid, SmsStatus } = req.body;
client const {
.request(queries.UPDATE_MESSAGE_STATUS, { 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, msid: SmsSid,
fields: { status: SmsStatus } fields: { status: SmsStatus }
}) });
.then((response) => {
const message = response.update_messages.returning[0];
if (message) {
logger.log("sms-status-update", "DEBUG", "api", null, { logger.log("sms-status-update", "DEBUG", "api", null, {
msid: SmsSid, msid: SmsSid,
fields: { status: SmsStatus } fields: { status: SmsStatus }
}); });
})
.catch((error) => { // Emit WebSocket event to notify the change in message status
logger.log("sms-status-update-error", "ERROR", "api", null, { 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, msid: SmsSid,
fields: { status: SmsStatus }, 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) => { exports.markConversationRead = async (req, res) => {
const { conversationid, imexshopid } = req.body; const {
admin.messaging().send({ ioRedis,
topic: `${imexshopid}-messaging`, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
// notification: { } = req;
// title: `ImEX Online Message - ${data.phone_num}`, const { conversation, imexshopid, bodyshopid } = req.body;
// 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);
};
// Inbound Sample // Alternatively, support both payload formats
// { const conversationId = conversation?.id || req.body.conversationId;
// "SmsSid": "SM5205ea340e06437799d9345e7283457c",
// "SmsStatus": "queued", if (!conversationId || !imexshopid || !bodyshopid) {
// "MessageStatus": "queued", return res.status(400).json({ error: "Invalid conversation data provided." });
// "To": "+16049992002", }
// "MessagingServiceSid": "MG6e259e2add04ffa0d0aa355038670ee1",
// "MessageSid": "SM5205ea340e06437799d9345e7283457c", try {
// "AccountSid": "AC6c09d337d6b9c68ab6488c2052bd457c", const response = await client.request(queries.MARK_MESSAGES_AS_READ, {
// "From": "+16043301606", conversationId
// "ApiVersion": "2010-04-01" });
// }
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

@@ -0,0 +1,26 @@
// Load environment variables THIS MUST BE AT THE TOP
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const { networkInterfaces, hostname } = require("node:os");
const getHostNameOrIP = () => {
// Try to get the hostname first
const hostName = hostname();
if (hostName) return hostName;
const interfaces = networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]) {
if (iface.family === "IPv4" && !iface.internal) {
return iface.address;
}
}
}
return "127.0.0.1";
};
module.exports = getHostNameOrIP;

View File

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

View File

@@ -8,10 +8,10 @@ const InstanceManager = require("../utils/instanceMgr").default;
const winston = require("winston"); const winston = require("winston");
const WinstonCloudWatch = require("winston-cloudwatch"); const WinstonCloudWatch = require("winston-cloudwatch");
const { isString, isEmpty } = require("lodash"); const { isString, isEmpty } = require("lodash");
const { networkInterfaces, hostname } = require("node:os");
const { uploadFileToS3 } = require("./s3"); const { uploadFileToS3 } = require("./s3");
const { v4 } = require("uuid"); const { v4 } = require("uuid");
const { InstanceRegion } = require("./instanceMgr"); const { InstanceRegion } = require("./instanceMgr");
const getHostNameOrIP = require("./getHostNameOrIP");
const LOG_LEVELS = { const LOG_LEVELS = {
error: { level: 0, name: "error" }, error: { level: 0, name: "error" },
@@ -76,22 +76,6 @@ const createLogger = () => {
})(); })();
}; };
const getHostNameOrIP = () => {
// Try to get the hostname first
const hostName = hostname();
if (hostName) return hostName;
const interfaces = networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]) {
if (iface.family === "IPv4" && !iface.internal) {
return iface.address;
}
}
}
return "127.0.0.1";
};
const createProductionTransport = (level, logStreamName, filters) => { const createProductionTransport = (level, logStreamName, filters) => {
return new WinstonCloudWatch({ return new WinstonCloudWatch({
level, level,

View File

@@ -0,0 +1,52 @@
// Load environment variables THIS MUST BE AT THE TOP
const path = require("path");
const getHostNameOrIP = require("./getHostNameOrIP");
const logger = require("./logger");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const CrispStatusReporter = require("crisp-status-reporter").CrispStatusReporter;
const InstanceManager = require("../utils/instanceMgr").default;
function StartStatusReporter() {
//For ImEX Online.
InstanceManager({
executeFunction: true,
args: [],
imex: () => {
if (
process.env.NODE_ENV === undefined ||
!process.env.CRISP_SECRET_TOKEN ||
!process.env.CRISP_SERVICE_IDENTIFIER ||
!process.env.CRISP_NODE_IDENTIFIER
) {
logger.log("crisp-status-update-error", "DEBUG", null, null, { message: "Environment Variables not set." });
return;
}
try {
const crispStatusReporter = new CrispStatusReporter({
token: process.env.CRISP_SECRET_TOKEN, // Your reporter token (given by Crisp)
service_id: process.env.CRISP_SERVICE_IDENTIFIER, // Service ID containing the parent Node for Replica (given by Crisp)
node_id: process.env.CRISP_NODE_IDENTIFIER, // Node ID containing Replica (given by Crisp)
replica_id: getHostNameOrIP(), // Unique Replica ID for instance (ie. your IP on the LAN)
interval: 30, // Reporting interval (in seconds; defaults to 30 seconds if not set)
console: {
debug: (log_message, data) => logger.log("crisp-status-update", "DEBUG", null, null, { log_message, data }),
log: (log_message, data) => logger.log("crisp-status-update", "DEBUG", null, null, { log_message, data }),
warn: (log_message, data) => logger.log("crisp-status-update", "WARN", null, null, { log_message, data }),
error: (log_message, data) => logger.log("crisp-status-update", "ERROR", null, null, { log_message, data })
} // Console instance if you need to debug issues,
});
return crispStatusReporter;
} catch (error) {
logger.log("crisp-status-update-error", "DEBUG", null, null, { error: error.message });
}
}
});
}
module.exports = StartStatusReporter;

View File

@@ -3,7 +3,7 @@ const { admin } = require("../firebase/firebase-handler");
const redisSocketEvents = ({ const redisSocketEvents = ({
io, io,
redisHelpers: { setSessionData, clearSessionData }, // Note: Used if we persist user to Redis redisHelpers: { setSessionData, clearSessionData }, // Note: Used if we persist user to Redis
ioHelpers: { getBodyshopRoom }, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom },
logger logger
}) => { }) => {
// Logging helper functions // Logging helper functions
@@ -46,18 +46,25 @@ const redisSocketEvents = ({
// Token Update Events // Token Update Events
const registerUpdateEvents = (socket) => { const registerUpdateEvents = (socket) => {
let latestTokenTimestamp = 0;
const updateToken = async (newToken) => { const updateToken = async (newToken) => {
const currentTimestamp = Date.now();
latestTokenTimestamp = currentTimestamp;
try { try {
// noinspection UnnecessaryLocalVariableJS // Verify token with Firebase Admin SDK
const user = await admin.auth().verifyIdToken(newToken, true); 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; socket.user = user;
// If We ever want to persist user Data across workers createLogEvent(socket, "debug", `Token updated successfully for socket ID: ${socket.id}`);
// await setSessionData(socket.id, "user", user);
// Uncomment for further testing
// createLogEvent(socket, "debug", "Token updated successfully");
socket.emit("token-updated", { success: true }); socket.emit("token-updated", { success: true });
} catch (error) { } catch (error) {
if (error.code === "auth/id-token-expired") { if (error.code === "auth/id-token-expired") {
@@ -66,16 +73,20 @@ const redisSocketEvents = ({
success: false, success: false,
error: "Stale token." error: "Stale token."
}); });
} else { return; // Avoid disconnecting for expired tokens
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();
} }
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); socket.on("update-token", updateToken);
}; };
// Room Broadcast Events // Room Broadcast Events
const registerRoomAndBroadcastEvents = (socket) => { const registerRoomAndBroadcastEvents = (socket) => {
const joinBodyshopRoom = (bodyshopUUID) => { const joinBodyshopRoom = (bodyshopUUID) => {
@@ -113,6 +124,7 @@ const redisSocketEvents = ({
socket.on("leave-bodyshop-room", leaveBodyshopRoom); socket.on("leave-bodyshop-room", leaveBodyshopRoom);
socket.on("broadcast-to-bodyshop", broadcastToBodyshopRoom); socket.on("broadcast-to-bodyshop", broadcastToBodyshopRoom);
}; };
// Disconnect Events // Disconnect Events
const registerDisconnectEvents = (socket) => { const registerDisconnectEvents = (socket) => {
const disconnect = () => { const disconnect = () => {
@@ -130,9 +142,64 @@ const redisSocketEvents = ({
socket.on("disconnect", disconnect); socket.on("disconnect", disconnect);
}; };
// Messaging Events
const registerMessagingEvents = (socket) => {
const joinConversationRoom = async ({ bodyshopId, conversationId }) => {
try {
const room = getBodyshopConversationRoom({ bodyshopId, conversationId });
socket.join(room);
} catch (error) {
logger.log("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 // Call Handlers
registerRoomAndBroadcastEvents(socket); registerRoomAndBroadcastEvents(socket);
registerUpdateEvents(socket); registerUpdateEvents(socket);
registerMessagingEvents(socket);
registerDisconnectEvents(socket); registerDisconnectEvents(socket);
}; };