Compare commits
37 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83a1952880 | ||
|
|
a885bdec74 | ||
|
|
11b906103a | ||
|
|
3f006f431e | ||
|
|
6f2b5e4c55 | ||
|
|
50d7c5dace | ||
|
|
9ac27b6090 | ||
|
|
51a1b48da9 | ||
|
|
648a9b8f64 | ||
|
|
7402679091 | ||
|
|
627174b7d3 | ||
|
|
9fcc01aa9f | ||
|
|
cb46ee5700 | ||
|
|
43bf1fc8cf | ||
|
|
73af18f287 | ||
|
|
90f4977924 | ||
|
|
c3b184d17b | ||
|
|
db5740d487 | ||
|
|
08c0da1bed | ||
|
|
4d35976241 | ||
|
|
5edbed3f0b | ||
|
|
3d79be06de | ||
|
|
2937a07379 | ||
|
|
ad1761096a | ||
|
|
6a7548d11b | ||
|
|
affbb3f168 | ||
|
|
0522747b49 | ||
|
|
aec7b40ae2 | ||
|
|
54d319f1e8 | ||
|
|
8d6fba2b61 | ||
|
|
70c31eae9e | ||
|
|
5e871b024d | ||
|
|
eb1786d634 | ||
|
|
5e8d0fddbd | ||
|
|
5d690fd71f | ||
|
|
79a2d902cd | ||
|
|
77f340d08c |
@@ -1,6 +1,6 @@
|
||||
// Scripts for firebase and firebase messaging
|
||||
importScripts("https://www.gstatic.com/firebasejs/8.2.0/firebase-app.js");
|
||||
importScripts("https://www.gstatic.com/firebasejs/8.2.0/firebase-messaging.js");
|
||||
importScripts("https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js");
|
||||
importScripts("https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js");
|
||||
|
||||
// Initialize the Firebase app in the service worker by passing the generated config
|
||||
let firebaseConfig;
|
||||
@@ -14,7 +14,7 @@ switch (this.location.hostname) {
|
||||
storageBucket: "imex-dev.appspot.com",
|
||||
messagingSenderId: "759548147434",
|
||||
appId: "1:759548147434:web:e8239868a48ceb36700993",
|
||||
measurementId: "G-K5XRBVVB4S",
|
||||
measurementId: "G-K5XRBVVB4S"
|
||||
};
|
||||
break;
|
||||
case "test.imex.online":
|
||||
@@ -24,7 +24,7 @@ switch (this.location.hostname) {
|
||||
projectId: "imex-test",
|
||||
storageBucket: "imex-test.appspot.com",
|
||||
messagingSenderId: "991923618608",
|
||||
appId: "1:991923618608:web:633437569cdad78299bef5",
|
||||
appId: "1:991923618608:web:633437569cdad78299bef5"
|
||||
// measurementId: "${config.measurementId}",
|
||||
};
|
||||
break;
|
||||
@@ -38,7 +38,7 @@ switch (this.location.hostname) {
|
||||
storageBucket: "imex-prod.appspot.com",
|
||||
messagingSenderId: "253497221485",
|
||||
appId: "1:253497221485:web:3c81c483b94db84b227a64",
|
||||
measurementId: "G-NTWBKG2L0M",
|
||||
measurementId: "G-NTWBKG2L0M"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,8 +49,6 @@ const messaging = firebase.messaging();
|
||||
|
||||
messaging.onBackgroundMessage(function (payload) {
|
||||
// Customize notification here
|
||||
const channel = new BroadcastChannel("imex-sw-messages");
|
||||
channel.postMessage(payload);
|
||||
|
||||
//self.registration.showNotification(notificationTitle, notificationOptions);
|
||||
console.log("[firebase-messaging-sw.js] Received background message ", payload);
|
||||
self.registration.showNotification(notificationTitle, notificationOptions);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useApolloClient } from "@apollo/client";
|
||||
import { getToken } from "@firebase/messaging";
|
||||
import axios from "axios";
|
||||
import React, { useContext, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext";
|
||||
import { messaging, requestForToken } from "../../firebase/firebase.utils";
|
||||
import ChatPopupComponent from "../chat-popup/chat-popup.component";
|
||||
import "./chat-affix.styles.scss";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext";
|
||||
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
|
||||
|
||||
export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
||||
@@ -14,6 +17,23 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
||||
useEffect(() => {
|
||||
if (!bodyshop || !bodyshop.messagingservicesid) return;
|
||||
|
||||
async function SubscribeToTopicForFCMNotification() {
|
||||
try {
|
||||
await requestForToken();
|
||||
await axios.post("/notifications/subscribe", {
|
||||
fcm_tokens: await getToken(messaging, {
|
||||
vapidKey: import.meta.env.VITE_APP_FIREBASE_PUBLIC_VAPID_KEY
|
||||
}),
|
||||
type: "messaging",
|
||||
imexshopid: bodyshop.imexshopid
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error attempting to subscribe to messaging topic: ", error);
|
||||
}
|
||||
}
|
||||
|
||||
SubscribeToTopicForFCMNotification();
|
||||
|
||||
//Register WS handlers
|
||||
if (socket && socket.connected) {
|
||||
registerMessagingHandlers({ socket, client });
|
||||
|
||||
@@ -2,39 +2,65 @@ import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql
|
||||
import { gql } from "@apollo/client";
|
||||
|
||||
const logLocal = (message, ...args) => {
|
||||
if (import.meta.env.PROD) {
|
||||
return;
|
||||
if (import.meta.env.VITE_APP_IS_TEST || !import.meta.env.PROD) {
|
||||
console.log(`==================== ${message} ====================`);
|
||||
console.dir({ ...args });
|
||||
}
|
||||
console.log(`==================== ${message} ====================`);
|
||||
console.dir({ ...args });
|
||||
};
|
||||
|
||||
// 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"
|
||||
});
|
||||
|
||||
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
|
||||
if (!existingConversation && conversationId) {
|
||||
// Attempt to read from the cache to determine if this is actually a new conversation
|
||||
try {
|
||||
const cachedConversation = client.cache.readFragment({
|
||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||
fragment: gql`
|
||||
fragment ExistingConversationCheck on conversations {
|
||||
id
|
||||
}
|
||||
`
|
||||
});
|
||||
|
||||
if (cachedConversation) {
|
||||
logLocal("handleNewMessageSummary - Existing Conversation inferred from cache", {
|
||||
conversationId
|
||||
});
|
||||
return handleNewMessageSummary({
|
||||
...message,
|
||||
existingConversation: true
|
||||
});
|
||||
}
|
||||
},
|
||||
__typename: "conversations"
|
||||
});
|
||||
} catch (error) {
|
||||
logLocal("handleNewMessageSummary - Cache miss", { conversationId });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle new conversation
|
||||
if (!existingConversation && newConversation?.phone_num) {
|
||||
@@ -49,7 +75,6 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
||||
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",
|
||||
@@ -68,81 +93,7 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
||||
|
||||
// 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: {
|
||||
@@ -166,7 +117,11 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
||||
} catch (error) {
|
||||
console.error("Error updating cache for existing conversation:", error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logLocal("New Conversation Summary finished without work", { message });
|
||||
};
|
||||
|
||||
const handleNewMessageDetailed = (message) => {
|
||||
@@ -391,8 +346,13 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||
fields: {
|
||||
job_conversations: (existing = [], { readField }) =>
|
||||
existing.filter((jobRef) => readField("jobid", jobRef) !== fields.jobId)
|
||||
job_conversations: (existing = [], { readField }) => {
|
||||
return existing.filter((jobRef) => {
|
||||
// Read the `jobid` field safely, even if the structure is normalized
|
||||
const jobId = readField("jobid", jobRef);
|
||||
return jobId !== fields.jobId;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
break;
|
||||
@@ -421,7 +381,6 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
||||
|
||||
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");
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ChatArchiveButton({ conversation, bodyshop }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleToggleArchive} loading={loading} type="primary">
|
||||
<Button onClick={handleToggleArchive} loading={loading} className="archive-button" type="primary">
|
||||
{conversation.archived ? t("messaging.labels.unarchive") : t("messaging.labels.archive")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Card, List, Space, Tag } from "antd";
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -20,8 +20,25 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
});
|
||||
|
||||
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) {
|
||||
// That comma is there for a reason, do not remove it
|
||||
const [, forceUpdate] = useState(false);
|
||||
|
||||
// Re-render every minute
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
forceUpdate((prev) => !prev); // Toggle state to trigger re-render
|
||||
}, 60000); // 1 minute in milliseconds
|
||||
|
||||
return () => clearInterval(interval); // Cleanup on unmount
|
||||
}, []);
|
||||
|
||||
// Memoize the sorted conversation list
|
||||
const sortedConversationList = React.useMemo(() => {
|
||||
return _.orderBy(conversationList, ["updated_at"], ["desc"]);
|
||||
}, [conversationList]);
|
||||
|
||||
const renderConversation = (index) => {
|
||||
const item = conversationList[index];
|
||||
const item = sortedConversationList[index];
|
||||
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
|
||||
const cardContentLeft =
|
||||
item.job_conversations.length > 0
|
||||
@@ -64,13 +81,10 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
|
||||
);
|
||||
};
|
||||
|
||||
// CAN DO: Can go back into virtuoso for additional fetch
|
||||
// endReached={loadMoreConversations} // Calls loadMoreConversations when scrolled to the bottom
|
||||
|
||||
return (
|
||||
<div className="chat-list-container">
|
||||
<Virtuoso
|
||||
data={conversationList}
|
||||
data={sortedConversationList}
|
||||
itemContent={(index) => renderConversation(index)}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
/>
|
||||
|
||||
@@ -20,10 +20,10 @@ export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
|
||||
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
|
||||
const { socket } = useContext(SocketContext);
|
||||
|
||||
const handleRemoveTag = (jobId) => {
|
||||
const handleRemoveTag = async (jobId) => {
|
||||
const convId = jobConversations[0].conversationid;
|
||||
if (!!convId) {
|
||||
removeJobConversation({
|
||||
await removeJobConversation({
|
||||
variables: {
|
||||
conversationId: convId,
|
||||
jobId: jobId
|
||||
@@ -38,17 +38,18 @@ export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}).then(() => {
|
||||
if (socket) {
|
||||
// Emit the `conversation-modified` event
|
||||
socket.emit("conversation-modified", {
|
||||
bodyshopId: bodyshop.id,
|
||||
conversationId: convId,
|
||||
type: "tag-removed",
|
||||
jobId: jobId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (socket) {
|
||||
// Emit the `conversation-modified` event
|
||||
socket.emit("conversation-modified", {
|
||||
bodyshopId: bodyshop.id,
|
||||
conversationId: convId,
|
||||
type: "tag-removed",
|
||||
jobId: jobId
|
||||
});
|
||||
}
|
||||
|
||||
logImEXEvent("messaging_remove_job_tag", {
|
||||
conversationId: convId,
|
||||
jobId: jobId
|
||||
|
||||
@@ -15,7 +15,7 @@ const mapDispatchToProps = () => ({});
|
||||
|
||||
export function ChatConversationTitle({ conversation }) {
|
||||
return (
|
||||
<Space wrap>
|
||||
<Space className="chat-title" wrap>
|
||||
<PhoneNumberFormatter>{conversation && conversation.phone_num}</PhoneNumberFormatter>
|
||||
<ChatLabelComponent conversation={conversation} />
|
||||
<ChatPrintButton conversation={conversation} />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useApolloClient, useQuery } from "@apollo/client";
|
||||
import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
||||
import axios from "axios";
|
||||
import React, { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext";
|
||||
import { GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
|
||||
import { GET_CONVERSATION_DETAILS, CONVERSATION_SUBSCRIPTION_BY_PK } from "../../graphql/conversations.queries";
|
||||
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import ChatConversationComponent from "./chat-conversation.component";
|
||||
@@ -14,11 +14,12 @@ const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
export function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
const client = useApolloClient();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
|
||||
|
||||
// Fetch conversation details
|
||||
const {
|
||||
loading: convoLoading,
|
||||
error: convoError,
|
||||
@@ -29,49 +30,80 @@ export function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
nextFetchPolicy: "network-only"
|
||||
});
|
||||
|
||||
const updateCacheWithReadMessages = useCallback(
|
||||
(conversationId, messageIds) => {
|
||||
if (!conversationId || !messageIds || messageIds.length === 0) return;
|
||||
// Subscription for conversation updates
|
||||
useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, {
|
||||
skip: socket?.connected,
|
||||
variables: { conversationId: selectedConversation },
|
||||
onData: ({ data: subscriptionResult, client }) => {
|
||||
// Extract the messages array from the result
|
||||
const messages = subscriptionResult?.data?.messages;
|
||||
if (!messages || messages.length === 0) {
|
||||
console.warn("No messages found in subscription result.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark individual messages as read
|
||||
messageIds.forEach((messageId) => {
|
||||
messages.forEach((message) => {
|
||||
const messageRef = client.cache.identify(message);
|
||||
// Write the new message to the cache
|
||||
client.cache.writeFragment({
|
||||
id: messageRef,
|
||||
fragment: gql`
|
||||
fragment NewMessage on messages {
|
||||
id
|
||||
status
|
||||
text
|
||||
isoutbound
|
||||
image
|
||||
image_path
|
||||
userid
|
||||
created_at
|
||||
read
|
||||
}
|
||||
`,
|
||||
data: message
|
||||
});
|
||||
|
||||
// Update the conversation cache to include the new message
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "messages", id: messageId }),
|
||||
id: client.cache.identify({ __typename: "conversations", id: selectedConversation }),
|
||||
fields: {
|
||||
read() {
|
||||
return true; // Mark message as read
|
||||
messages(existingMessages = []) {
|
||||
const alreadyExists = existingMessages.some((msg) => msg.__ref === messageRef);
|
||||
if (alreadyExists) return existingMessages;
|
||||
return [...existingMessages, { __ref: messageRef }];
|
||||
},
|
||||
updated_at() {
|
||||
return message.created_at;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update aggregate unread count for the conversation
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||
fields: {
|
||||
messages_aggregate(existingAggregate) {
|
||||
return {
|
||||
...existingAggregate,
|
||||
aggregate: {
|
||||
...existingAggregate.aggregate,
|
||||
count: 0 // No unread messages remaining
|
||||
}
|
||||
};
|
||||
const updateCacheWithReadMessages = useCallback(
|
||||
(conversationId, messageIds) => {
|
||||
if (!conversationId || !messageIds?.length) return;
|
||||
|
||||
messageIds.forEach((messageId) => {
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "messages", id: messageId }),
|
||||
fields: {
|
||||
read: () => true
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
[client.cache]
|
||||
);
|
||||
|
||||
// Handle WebSocket events
|
||||
// WebSocket event handlers
|
||||
useEffect(() => {
|
||||
if (!socket || !socket.connected) return;
|
||||
if (!socket?.connected) return;
|
||||
|
||||
const handleConversationChange = (data) => {
|
||||
if (data.type === "conversation-marked-read") {
|
||||
const { conversationId, messageIds } = data;
|
||||
console.log("Conversation change received:", data);
|
||||
updateCacheWithReadMessages(conversationId, messageIds);
|
||||
}
|
||||
};
|
||||
@@ -81,11 +113,11 @@ export function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
return () => {
|
||||
socket.off("conversation-changed", handleConversationChange);
|
||||
};
|
||||
}, [socket, client, updateCacheWithReadMessages]);
|
||||
}, [socket, updateCacheWithReadMessages]);
|
||||
|
||||
// Handle joining/leaving conversation
|
||||
// Join and leave conversation via WebSocket
|
||||
useEffect(() => {
|
||||
if (!socket || !socket.connected) return;
|
||||
if (!socket?.connected || !selectedConversation || !bodyshop?.id) return;
|
||||
|
||||
socket.emit("join-bodyshop-conversation", {
|
||||
bodyshopId: bodyshop.id,
|
||||
@@ -98,17 +130,14 @@ export function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
conversationId: selectedConversation
|
||||
});
|
||||
};
|
||||
}, [selectedConversation, bodyshop, socket]);
|
||||
}, [socket, bodyshop, selectedConversation]);
|
||||
|
||||
// Handle marking conversation as read
|
||||
// Mark conversation as read
|
||||
const handleMarkConversationAsRead = async () => {
|
||||
if (!convoData || !selectedConversation || markingAsReadInProgress) return;
|
||||
if (!convoData || markingAsReadInProgress) return;
|
||||
|
||||
const conversation = convoData.conversations_by_pk;
|
||||
if (!conversation) {
|
||||
console.warn(`No data found for conversation ID: ${selectedConversation}`);
|
||||
return;
|
||||
}
|
||||
if (!conversation) return;
|
||||
|
||||
const unreadMessageIds = conversation.messages
|
||||
?.filter((message) => !message.read && !message.isoutbound)
|
||||
@@ -116,22 +145,16 @@ export function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
|
||||
if (unreadMessageIds?.length > 0) {
|
||||
setMarkingAsReadInProgress(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
await axios.post("/sms/markConversationRead", {
|
||||
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);
|
||||
console.error("Error marking conversation as read:", error.message);
|
||||
} finally {
|
||||
setMarkingAsReadInProgress(false);
|
||||
}
|
||||
@@ -141,11 +164,11 @@ export function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
return (
|
||||
<ChatConversationComponent
|
||||
subState={[convoLoading, convoError]}
|
||||
conversation={convoData ? convoData.conversations_by_pk : {}}
|
||||
messages={convoData ? convoData.conversations_by_pk.messages : []}
|
||||
conversation={convoData?.conversations_by_pk || {}}
|
||||
messages={convoData?.conversations_by_pk?.messages || []}
|
||||
handleMarkConversationAsRead={handleMarkConversationAsRead}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, null)(ChatConversationContainer);
|
||||
export default connect(mapStateToProps)(ChatConversationContainer);
|
||||
|
||||
@@ -1,53 +1,85 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { renderMessage } from "./renderMessage";
|
||||
import "./chat-message-list.styles.scss";
|
||||
|
||||
const SCROLL_DELAY_MS = 50;
|
||||
const INITIAL_SCROLL_DELAY_MS = 100;
|
||||
|
||||
export default function ChatMessageListComponent({ messages }) {
|
||||
const virtuosoRef = useRef(null);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const loadedImagesRef = useRef(0);
|
||||
|
||||
// Scroll to the bottom after a short delay when the component mounts
|
||||
const handleScrollStateChange = (isAtBottom) => {
|
||||
setAtBottom(isAtBottom);
|
||||
};
|
||||
|
||||
const resetImageLoadState = () => {
|
||||
loadedImagesRef.current = 0;
|
||||
};
|
||||
|
||||
const preloadImages = (imagePaths, onComplete) => {
|
||||
resetImageLoadState();
|
||||
|
||||
if (imagePaths.length === 0) {
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
imagePaths.forEach((url) => {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
img.onload = img.onerror = () => {
|
||||
loadedImagesRef.current += 1;
|
||||
if (loadedImagesRef.current === imagePaths.length) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Ensure all images are loaded on initial render
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (virtuosoRef?.current?.scrollToIndex && messages?.length) {
|
||||
const imagePaths = messages
|
||||
.filter((message) => message.image && message.image_path?.length > 0)
|
||||
.flatMap((message) => message.image_path);
|
||||
|
||||
preloadImages(imagePaths, () => {
|
||||
if (virtuosoRef.current) {
|
||||
virtuosoRef.current.scrollToIndex({
|
||||
index: messages.length - 1,
|
||||
behavior: "auto" // Instantly scroll to the bottom
|
||||
align: "end",
|
||||
behavior: "auto"
|
||||
});
|
||||
}
|
||||
}, INITIAL_SCROLL_DELAY_MS);
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
// Cleanup the timeout on unmount
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // ESLint is disabled for this line because we only want this to load once (valid exception)
|
||||
|
||||
// Scroll to the bottom after the new messages are rendered
|
||||
// Handle scrolling when new messages are added
|
||||
useEffect(() => {
|
||||
if (virtuosoRef?.current?.scrollToIndex && messages?.length) {
|
||||
const timeout = setTimeout(() => {
|
||||
if (!atBottom) return;
|
||||
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
const imagePaths = latestMessage?.image_path || [];
|
||||
|
||||
preloadImages(imagePaths, () => {
|
||||
if (virtuosoRef.current) {
|
||||
virtuosoRef.current.scrollToIndex({
|
||||
index: messages.length - 1,
|
||||
align: "end", // Ensure the last message is fully visible
|
||||
behavior: "smooth" // Smooth scrolling
|
||||
align: "end",
|
||||
behavior: "smooth"
|
||||
});
|
||||
}, SCROLL_DELAY_MS); // Slight delay to ensure layout recalculates
|
||||
|
||||
// Cleanup timeout on dependency changes
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [messages]); // Triggered when new messages are added
|
||||
}
|
||||
});
|
||||
}, [messages, atBottom]);
|
||||
|
||||
return (
|
||||
<div className="chat">
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={messages}
|
||||
itemContent={(index) => renderMessage(messages, index)} // Pass `messages` to renderMessage
|
||||
followOutput="smooth" // Ensure smooth scrolling when new data is appended
|
||||
overscan={!!messages.reduce((acc, message) => acc + (message.image_path?.length || 0), 0) ? messages.length : 0}
|
||||
itemContent={(index) => renderMessage(messages, index)}
|
||||
followOutput={(isAtBottom) => handleScrollStateChange(isAtBottom)}
|
||||
initialTopMostItemIndex={messages.length - 1}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,110 +1,131 @@
|
||||
.message-icon {
|
||||
color: whitesmoke;
|
||||
border: #000000;
|
||||
position: absolute;
|
||||
margin: 0 0.1rem;
|
||||
bottom: 0.1rem;
|
||||
right: 0.3rem;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.chat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0.8rem 0rem;
|
||||
overflow: hidden; // Ensure the content scrolls correctly
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.archive-button {
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.chat-title {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem; // Add padding to avoid edge clipping
|
||||
padding: 0.5rem; // Prevent edge clipping
|
||||
}
|
||||
|
||||
.message {
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
padding: 0.25rem 0.8rem;
|
||||
word-wrap: break-word;
|
||||
|
||||
.message-img {
|
||||
&-img {
|
||||
max-width: 10rem;
|
||||
max-height: 10rem;
|
||||
object-fit: contain;
|
||||
margin: 0.2rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.yours {
|
||||
align-items: flex-start;
|
||||
.chat-send-message-button{
|
||||
margin: 0.3rem;
|
||||
padding-left: 0.5rem;
|
||||
|
||||
}
|
||||
.message-icon {
|
||||
position: absolute;
|
||||
bottom: 0.1rem;
|
||||
right: 0.3rem;
|
||||
margin: 0 0.1rem;
|
||||
color: whitesmoke;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.msgmargin {
|
||||
margin-top: 0.1rem;
|
||||
margin-bottom: 0.1rem;
|
||||
margin: 0.1rem 0;
|
||||
}
|
||||
|
||||
.yours .message {
|
||||
margin-right: 20%;
|
||||
background-color: #eee;
|
||||
position: relative;
|
||||
.yours,
|
||||
.mine {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.message {
|
||||
position: relative;
|
||||
|
||||
&:last-child:before,
|
||||
&:last-child:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&:last-child:after {
|
||||
width: 10px;
|
||||
background: white;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.yours .message:last-child:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
bottom: 0;
|
||||
left: -7px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
background: #eee;
|
||||
border-bottom-right-radius: 15px;
|
||||
}
|
||||
|
||||
.yours .message:last-child:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: 0;
|
||||
left: -10px;
|
||||
width: 10px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-bottom-right-radius: 10px;
|
||||
/* "Yours" (incoming) message styles */
|
||||
.yours {
|
||||
align-items: flex-start;
|
||||
|
||||
.message {
|
||||
margin-right: 20%;
|
||||
background-color: #eee;
|
||||
|
||||
&:last-child:before {
|
||||
left: -7px;
|
||||
background: #eee;
|
||||
border-bottom-right-radius: 15px;
|
||||
}
|
||||
|
||||
&:last-child:after {
|
||||
left: -10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* "Mine" (outgoing) message styles */
|
||||
.mine {
|
||||
align-items: flex-end;
|
||||
|
||||
.message {
|
||||
color: white;
|
||||
margin-left: 25%;
|
||||
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
|
||||
padding-bottom: 0.6rem;
|
||||
|
||||
&:last-child:before {
|
||||
right: -8px;
|
||||
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
|
||||
border-bottom-left-radius: 15px;
|
||||
}
|
||||
|
||||
&:last-child:after {
|
||||
right: -10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mine .message {
|
||||
color: white;
|
||||
margin-left: 25%;
|
||||
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
|
||||
position: relative;
|
||||
padding-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.mine .message:last-child:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
bottom: 0;
|
||||
right: -8px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
|
||||
border-bottom-left-radius: 15px;
|
||||
}
|
||||
|
||||
.mine .message:last-child:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: 0;
|
||||
right: -10px;
|
||||
width: 10px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-bottom-left-radius: 10px;
|
||||
.virtuoso-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -7,28 +7,38 @@ 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>
|
||||
{/* Render images if available */}
|
||||
{message.image && message.image_path?.length > 0 && (
|
||||
<div className="message-images">
|
||||
{message.image_path.map((url, idx) => (
|
||||
<div key={idx} style={{ display: "flex", justifyContent: "center" }}>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<img alt="Received" className="message-img" src={url} />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Render text if available */}
|
||||
{message.text && <div>{message.text}</div>}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Message status icons */}
|
||||
{message.status && (message.status === "sent" || message.status === "delivered") && (
|
||||
<div className="message-status">
|
||||
<Icon component={message.status === "sent" ? MdDone : MdDoneAll} className="message-icon" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Outbound message metadata */}
|
||||
{message.isoutbound && (
|
||||
<div style={{ fontSize: 10 }}>
|
||||
{i18n.t("messaging.labels.sentby", {
|
||||
|
||||
@@ -108,7 +108,7 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
||||
<InfoCircleOutlined />
|
||||
</Tooltip>
|
||||
<SyncOutlined style={{ cursor: "pointer" }} onClick={() => refetch()} />
|
||||
{pollInterval > 0 && <Tag color="yellow">{t("messaging.labels.nopush")}</Tag>}
|
||||
{!socket?.connected && <Tag color="yellow">{t("messaging.labels.nopush")}</Tag>}
|
||||
</Space>
|
||||
<ShrinkOutlined
|
||||
onClick={() => toggleChatVisible()}
|
||||
|
||||
@@ -81,7 +81,7 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
|
||||
/>
|
||||
</span>
|
||||
<SendOutlined
|
||||
className="imex-flex-row__margin"
|
||||
className="chat-send-message-button"
|
||||
// disabled={message === "" || !message}
|
||||
onClick={handleEnter}
|
||||
/>
|
||||
|
||||
@@ -28,7 +28,7 @@ export function ChatTagRoContainer({ conversation, bodyshop }) {
|
||||
|
||||
const executeSearch = (v) => {
|
||||
logImEXEvent("messaging_search_job_tag", { searchTerm: v });
|
||||
loadRo(v);
|
||||
loadRo(v).catch((e) => console.error("Error in ChatTagRoContainer executeSearch:", e));
|
||||
};
|
||||
|
||||
const debouncedExecuteSearch = _.debounce(executeSearch, 500);
|
||||
@@ -41,33 +41,34 @@ export function ChatTagRoContainer({ conversation, bodyshop }) {
|
||||
variables: { conversationId: conversation.id }
|
||||
});
|
||||
|
||||
const handleInsertTag = (value, option) => {
|
||||
const handleInsertTag = async (value, option) => {
|
||||
logImEXEvent("messaging_add_job_tag");
|
||||
|
||||
insertTag({
|
||||
await 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]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -85,9 +86,10 @@ export function ChatTagRoContainer({ conversation, bodyshop }) {
|
||||
handleSearch={handleSearch}
|
||||
handleInsertTag={handleInsertTag}
|
||||
setOpen={setOpen}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
) : (
|
||||
<Tag onClick={() => setOpen(true)}>
|
||||
<Tag style={{ cursor: "pointer" }} onClick={() => setOpen(true)}>
|
||||
<PlusOutlined />
|
||||
{t("messaging.actions.link")}
|
||||
</Tag>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { gql, useApolloClient, useLazyQuery, useMutation, useQuery } from "@apollo/client";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { Button, Col, Row, notification } from "antd";
|
||||
import { Col, Row, notification } from "antd"; //import { Button, Col, Row, notification } from "antd";
|
||||
import Axios from "axios";
|
||||
import _ from "lodash";
|
||||
import queryString from "query-string";
|
||||
@@ -408,8 +408,8 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
|
||||
updateSchComp={updateSchComp}
|
||||
setSchComp={setSchComp}
|
||||
/>
|
||||
{
|
||||
{/* currentUser.email.includes("@rome.") || currentUser.email.includes("@imex.") ? (
|
||||
{/* {
|
||||
currentUser.email.includes("@rome.") || currentUser.email.includes("@imex.") ? (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
for (const record of data.available_jobs) {
|
||||
@@ -425,8 +425,8 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
|
||||
>
|
||||
Add all jobs as new.
|
||||
</Button>
|
||||
) : null */}
|
||||
}
|
||||
) : null
|
||||
} */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<JobsAvailableTableComponent
|
||||
|
||||
@@ -65,13 +65,10 @@ export const requestForToken = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const onMessageListener = () =>
|
||||
new Promise((resolve) => {
|
||||
onMessage(messaging, (payload) => {
|
||||
console.log("Inbound FCM Message", payload);
|
||||
resolve(payload);
|
||||
});
|
||||
});
|
||||
onMessage(messaging, (payload) => {
|
||||
console.log("FCM Message received. ", payload);
|
||||
// ...
|
||||
});
|
||||
|
||||
export const logImEXEvent = (eventName, additionalParams, stateProp = null) => {
|
||||
const state = stateProp || store.getState();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { gql } from "@apollo/client";
|
||||
|
||||
export const GET_ALL_JOBLINES_BY_PK = gql`
|
||||
query GET_ALL_JOBLINES_BY_PK($id: uuid!) {
|
||||
joblines(where: { jobid: { _eq: $id } }, order_by: { line_no: asc }) {
|
||||
joblines(where: { jobid: { _eq: $id }, removed: { _eq: false } }, order_by: { line_no: asc }) {
|
||||
id
|
||||
line_no
|
||||
unq_seq
|
||||
|
||||
@@ -145,47 +145,34 @@ middlewares.push(
|
||||
|
||||
const cache = new InMemoryCache({
|
||||
typePolicies: {
|
||||
Query: {
|
||||
fields: {
|
||||
conversations: offsetLimitPagination()
|
||||
}
|
||||
},
|
||||
conversations: {
|
||||
fields: {
|
||||
job_conversations: {
|
||||
keyArgs: false, // Indicates that all job_conversations share the same key
|
||||
merge(existing = [], incoming) {
|
||||
// Merge existing and incoming job_conversations
|
||||
const merged = [
|
||||
...existing,
|
||||
...incoming.filter(
|
||||
(incomingItem) => !existing.some((existingItem) => existingItem.__ref === incomingItem.__ref)
|
||||
)
|
||||
];
|
||||
return merged;
|
||||
}
|
||||
},
|
||||
messages: {
|
||||
keyArgs: false, // Ignore arguments when determining uniqueness (like `order_by`).
|
||||
merge(existing = [], incoming = [], { readField }) {
|
||||
const existingIds = new Set(existing.map((message) => readField("id", message)));
|
||||
const merged = new Map();
|
||||
|
||||
// Merge incoming messages, avoiding duplicates
|
||||
const merged = [...existing];
|
||||
incoming.forEach((message) => {
|
||||
if (!existingIds.has(readField("id", message))) {
|
||||
merged.push(message);
|
||||
}
|
||||
// Add existing data to the map
|
||||
existing.forEach((jobConversation) => {
|
||||
// Use `readField` to get the unique `jobid`, fallback to `__ref`
|
||||
const jobId = readField("jobid", jobConversation) || jobConversation.__ref;
|
||||
if (jobId) merged.set(jobId, jobConversation);
|
||||
});
|
||||
|
||||
return merged;
|
||||
// Add or replace with incoming data
|
||||
incoming.forEach((jobConversation) => {
|
||||
// Use `readField` to get the unique `jobid`, fallback to `__ref`
|
||||
const jobId = readField("jobid", jobConversation) || jobConversation.__ref;
|
||||
if (jobId) merged.set(jobId, jobConversation);
|
||||
});
|
||||
|
||||
// Return the merged data as an array
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const client = new ApolloClient({
|
||||
link: ApolloLink.from(middlewares),
|
||||
cache,
|
||||
|
||||
320
package-lock.json
generated
320
package-lock.json
generated
@@ -41,6 +41,7 @@
|
||||
"intuit-oauth": "^4.1.3",
|
||||
"ioredis": "^5.4.1",
|
||||
"json-2-csv": "^5.5.6",
|
||||
"juice": "^11.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.46",
|
||||
@@ -55,7 +56,7 @@
|
||||
"soap": "^1.1.6",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-adapter": "^2.5.5",
|
||||
"ssh2-sftp-client": "^10.0.3",
|
||||
"ssh2-sftp-client": "^11.0.0",
|
||||
"twilio": "^4.23.0",
|
||||
"uuid": "^10.0.0",
|
||||
"winston": "^3.17.0",
|
||||
@@ -66,6 +67,7 @@
|
||||
"devDependencies": {
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"p-limit": "^3.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"source-map-explorer": "^2.5.2"
|
||||
},
|
||||
@@ -3358,6 +3360,15 @@
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-colors": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -3926,6 +3937,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/component-emitter": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
|
||||
@@ -4603,6 +4623,31 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-sniffer": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz",
|
||||
"integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.3",
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||
@@ -4689,6 +4734,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-goat": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz",
|
||||
"integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@@ -6045,6 +6102,69 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/juice": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/juice/-/juice-11.0.0.tgz",
|
||||
"integrity": "sha512-sGF8hPz9/Wg+YXbaNDqc1Iuoaw+J/P9lBHNQKXAGc9pPNjCd4fyPai0Zxj7MRtdjMr0lcgk5PjEIkP2b8R9F3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cheerio": "^1.0.0",
|
||||
"commander": "^12.1.0",
|
||||
"mensch": "^0.3.4",
|
||||
"slick": "^1.12.2",
|
||||
"web-resource-inliner": "^7.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"juice": "bin/juice"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/juice/node_modules/cheerio": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz",
|
||||
"integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cheerio-select": "^2.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.1.0",
|
||||
"encoding-sniffer": "^0.2.0",
|
||||
"htmlparser2": "^9.1.0",
|
||||
"parse5": "^7.1.2",
|
||||
"parse5-htmlparser2-tree-adapter": "^7.0.0",
|
||||
"parse5-parser-stream": "^7.1.2",
|
||||
"undici": "^6.19.5",
|
||||
"whatwg-mimetype": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/juice/node_modules/htmlparser2": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz",
|
||||
"integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.1.0",
|
||||
"entities": "^4.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz",
|
||||
@@ -6297,6 +6417,12 @@
|
||||
"cssom": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mensch": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.4.tgz",
|
||||
"integrity": "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
@@ -6707,8 +6833,8 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
},
|
||||
@@ -6779,6 +6905,18 @@
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-parser-stream": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -7737,16 +7875,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ssh2-sftp-client": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-10.0.3.tgz",
|
||||
"integrity": "sha512-Wlhasz/OCgrlqC8IlBZhF19Uw/X/dHI8ug4sFQybPE+0sDztvgvDf7Om6o7LbRLe68E7XkFZf3qMnqAvqn1vkQ==",
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-11.0.0.tgz",
|
||||
"integrity": "sha512-lOjgNYtioYquhtgyHwPryFNhllkuENjvCKkUXo18w/Q4UpEffCnEUBfiOTlwFdKIhG1rhrOGnA6DeKPSF2CP6w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"concat-stream": "^2.0.0",
|
||||
"promise-retry": "^2.0.1",
|
||||
"ssh2": "^1.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.20.2"
|
||||
"node": ">=18.20.4"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
@@ -8292,6 +8431,15 @@
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz",
|
||||
"integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.19.8",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
||||
@@ -8353,6 +8501,15 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/valid-data-url": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-3.0.1.tgz",
|
||||
"integrity": "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@@ -8377,6 +8534,131 @@
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-resource-inliner": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-7.0.0.tgz",
|
||||
"integrity": "sha512-NlfnGF8MY9ZUwFjyq3vOUBx7KwF8bmE+ywR781SB0nWB6MoMxN4BA8gtgP1KGTZo/O/AyWJz7HZpR704eaj4mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-colors": "^4.1.1",
|
||||
"escape-goat": "^3.0.0",
|
||||
"htmlparser2": "^5.0.0",
|
||||
"mime": "^2.4.6",
|
||||
"valid-data-url": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-resource-inliner/node_modules/dom-serializer": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
|
||||
"integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.0.1",
|
||||
"domhandler": "^4.2.0",
|
||||
"entities": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/web-resource-inliner/node_modules/dom-serializer/node_modules/domhandler": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
|
||||
"integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/web-resource-inliner/node_modules/domhandler": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz",
|
||||
"integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/web-resource-inliner/node_modules/domutils": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||
"integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^1.0.1",
|
||||
"domelementtype": "^2.2.0",
|
||||
"domhandler": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/web-resource-inliner/node_modules/domutils/node_modules/domhandler": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
|
||||
"integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/web-resource-inliner/node_modules/entities": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
|
||||
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/web-resource-inliner/node_modules/htmlparser2": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-5.0.1.tgz",
|
||||
"integrity": "sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.0.1",
|
||||
"domhandler": "^3.3.0",
|
||||
"domutils": "^2.4.2",
|
||||
"entities": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/htmlparser2?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/web-resource-inliner/node_modules/mime": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
@@ -8405,6 +8687,30 @@
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
@@ -8691,8 +8997,8 @@
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"intuit-oauth": "^4.1.3",
|
||||
"ioredis": "^5.4.1",
|
||||
"json-2-csv": "^5.5.6",
|
||||
"juice": "^11.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.46",
|
||||
|
||||
41
server.js
41
server.js
@@ -24,6 +24,8 @@ const { ElastiCacheClient, DescribeCacheClustersCommand } = require("@aws-sdk/cl
|
||||
const { InstanceRegion } = require("./server/utils/instanceMgr");
|
||||
const StartStatusReporter = require("./server/utils/statusReporter");
|
||||
|
||||
const cleanupTasks = [];
|
||||
let isShuttingDown = false;
|
||||
const CLUSTER_RETRY_BASE_DELAY = 100;
|
||||
const CLUSTER_RETRY_MAX_DELAY = 5000;
|
||||
const CLUSTER_RETRY_JITTER = 100;
|
||||
@@ -298,7 +300,14 @@ const main = async () => {
|
||||
applyRoutes({ app });
|
||||
redisSocketEvents({ io: ioRedis, redisHelpers, ioHelpers, logger });
|
||||
|
||||
StartStatusReporter();
|
||||
const StatusReporter = StartStatusReporter();
|
||||
registerCleanupTask(async () => {
|
||||
StatusReporter.end();
|
||||
});
|
||||
|
||||
// Add SIGTERM signal handler
|
||||
process.on("SIGTERM", handleSigterm);
|
||||
process.on("SIGINT", handleSigterm); // Optional: Handle Ctrl+C
|
||||
|
||||
try {
|
||||
await server.listen(port);
|
||||
@@ -317,3 +326,33 @@ main().catch((error) => {
|
||||
// Note: If we want the app to crash on all uncaught async operations, we would
|
||||
// need to put a `process.exit(1);` here
|
||||
});
|
||||
|
||||
// Register a cleanup task
|
||||
function registerCleanupTask(task) {
|
||||
cleanupTasks.push(task);
|
||||
}
|
||||
|
||||
// SIGTERM handler
|
||||
async function handleSigterm() {
|
||||
if (isShuttingDown) {
|
||||
logger.log("sigterm-api", "WARN", null, null, { message: "Shutdown already in progress, ignoring signal." });
|
||||
return;
|
||||
}
|
||||
|
||||
isShuttingDown = true;
|
||||
|
||||
logger.log("sigterm-api", "WARN", null, null, { message: "SIGTERM Received. Starting graceful shutdown." });
|
||||
|
||||
try {
|
||||
for (const task of cleanupTasks) {
|
||||
logger.log("sigterm-api", "WARN", null, null, { message: `Running cleanup task: ${task.name}` });
|
||||
|
||||
await task();
|
||||
}
|
||||
logger.log("sigterm-api", "WARN", null, null, { message: `All cleanup tasks completed.` });
|
||||
} catch (error) {
|
||||
logger.log("sigterm-api-error", "ERROR", null, null, { message: error.message, stack: error.stack });
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ const sendNotification = async (req, res) => {
|
||||
admin
|
||||
.messaging()
|
||||
.send({
|
||||
topic: "PRD_PATRICK-messaging",
|
||||
topic: req.body.topic,
|
||||
notification: {
|
||||
title: `ImEX Online Message - `,
|
||||
body: "Test Noti."
|
||||
|
||||
@@ -14,7 +14,7 @@ require("dotenv").config({
|
||||
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
|
||||
});
|
||||
|
||||
const domain = process.env.NODE_ENV ? "secure" : "secure";
|
||||
const domain = process.env.NODE_ENV ? "secure" : "test";
|
||||
|
||||
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
|
||||
const { InstanceRegion } = require("../utils/instanceMgr");
|
||||
|
||||
@@ -857,8 +857,8 @@ function GenerateCostingData(job) {
|
||||
summaryData.totalSales = summaryData.totalSales.add(Adjustment);
|
||||
//Add to lines.
|
||||
costCenterData.push({
|
||||
id: "Adj",
|
||||
cost_center: "Adjustment",
|
||||
id: "AdjEst",
|
||||
cost_center: "Adjustment (Est. Match)",
|
||||
sale_labor: Dinero().toFormat(),
|
||||
sale_labor_dinero: Dinero(),
|
||||
sale_parts: Dinero().toFormat(),
|
||||
|
||||
@@ -73,7 +73,16 @@ async function TotalsServerSide(req, res) {
|
||||
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;
|
||||
const ttlDifference =
|
||||
emsTotal -
|
||||
ret.totals.subtotal
|
||||
.add(
|
||||
Dinero({
|
||||
amount: Math.round((job.adjustment_bottom_line || 0) * 100)
|
||||
}).multiply(-1) //Add back in the adjustment to the subtotal. We don't want to scrub it twice.
|
||||
)
|
||||
.getAmount() /
|
||||
100;
|
||||
|
||||
if (Math.abs(ttlDifference) > 0.0) {
|
||||
//If difference is greater than a pennny, we need to adjust it.
|
||||
|
||||
20
server/middleware/validateCanvasRequestMiddleware.js
Normal file
20
server/middleware/validateCanvasRequestMiddleware.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const { isObject } = require("lodash");
|
||||
|
||||
const validateCanvasRequestMiddleware = (req, res, next) => {
|
||||
const { w, h, values, keys, override } = req.body;
|
||||
if (!values || !keys) {
|
||||
return res.status(400).send("Missing required data");
|
||||
}
|
||||
if (override && !isObject(override)) {
|
||||
return res.status(400).send("Override must be an object");
|
||||
}
|
||||
if (w && (!Number.isFinite(w) || w <= 0)) {
|
||||
return res.status(400).send("Width must be a positive number");
|
||||
}
|
||||
if (h && (!Number.isFinite(h) || h <= 0)) {
|
||||
return res.status(400).send("Height must be a positive number");
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = validateCanvasRequestMiddleware;
|
||||
@@ -5,89 +5,120 @@ const logger = require("../utils/logger");
|
||||
const { backgroundColors, borderColors } = require("./canvas-colors");
|
||||
const { isObject, defaultsDeep, isNumber } = require("lodash");
|
||||
|
||||
let isProcessing = false;
|
||||
const requestQueue = [];
|
||||
|
||||
const processCanvasRequest = async (req, res) => {
|
||||
try {
|
||||
const { w, h, values, keys, override } = req.body;
|
||||
logger.log("inbound-canvas-creation", "debug", "jsr", null, { w, h, values, keys, override });
|
||||
|
||||
// Set dimensions with defaults
|
||||
const width = isNumber(w) ? w : 500;
|
||||
const height = isNumber(h) ? h : 275;
|
||||
|
||||
const configuration = {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: keys,
|
||||
datasets: [
|
||||
{
|
||||
data: values,
|
||||
backgroundColor: backgroundColors,
|
||||
borderColor: borderColors,
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
animation: false,
|
||||
devicePixelRatio: 4,
|
||||
responsive: false,
|
||||
maintainAspectRatio: true,
|
||||
circumference: 180,
|
||||
rotation: -90,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
boxWidth: 20,
|
||||
font: {
|
||||
family: "'Montserrat'",
|
||||
size: 10,
|
||||
style: "normal",
|
||||
weight: "normal"
|
||||
}
|
||||
},
|
||||
position: "left"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// If we have a valid override object, merge it with the default configuration object.
|
||||
// This allows for you to override the default configuration with a custom one.
|
||||
const defaults = () => {
|
||||
if (!override || !isObject(override)) {
|
||||
return configuration;
|
||||
}
|
||||
return defaultsDeep(override, configuration);
|
||||
};
|
||||
|
||||
// Generate chart
|
||||
let canvas = createCanvas(width, height);
|
||||
let ctx = canvas.getContext("2d");
|
||||
let chart = new Chart(ctx, defaults());
|
||||
const result = canvas.toDataURL();
|
||||
|
||||
chart.destroy();
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
ctx = null;
|
||||
canvas = null;
|
||||
chart = null;
|
||||
|
||||
res.status(200).send(result);
|
||||
} catch (error) {
|
||||
if (chart) chart.destroy();
|
||||
if (canvas) {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
ctx = null;
|
||||
canvas = null;
|
||||
chart = null;
|
||||
|
||||
logger.log("inbound-canvas-creation", "error", "jsr", null, { error: error.message, stack: error.stack });
|
||||
res.status(500).send("Error generating canvas");
|
||||
}
|
||||
};
|
||||
|
||||
const processNextInQueue = async () => {
|
||||
if (requestQueue.length === 0) {
|
||||
isProcessing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const { req, res } = requestQueue.shift();
|
||||
await processCanvasRequest(req, res);
|
||||
processNextInQueue();
|
||||
};
|
||||
|
||||
exports.canvastest = function (req, res) {
|
||||
//console.log("Incoming test request.", req);
|
||||
res.status(200).send("OK");
|
||||
};
|
||||
|
||||
exports.canvas = function (req, res) {
|
||||
const { w, h, values, keys, override } = req.body;
|
||||
//console.log("Incoming Canvas Request:", w, h, values, keys, override);
|
||||
logger.log("inbound-canvas-creation", "debug", "jsr", null, { w, h, values, keys, override });
|
||||
// Gate required values
|
||||
if (!values || !keys) {
|
||||
res.status(400).send("Missing required data");
|
||||
exports.canvas = async function (req, res) {
|
||||
if (isProcessing) {
|
||||
if (requestQueue.length >= 100) {
|
||||
// Set a maximum queue size
|
||||
return res.status(503).send("Server is busy. Please try again later.");
|
||||
}
|
||||
requestQueue.push({ req, res });
|
||||
logger.log("inbound-canvas-creation-queue", "debug", "jsr", null, { queue: requestQueue.length });
|
||||
return;
|
||||
}
|
||||
|
||||
// Override must be an object if it exists
|
||||
if (override && !isObject(override)) {
|
||||
res.status(400).send("Override must be an object");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the default Width and Height
|
||||
let [width, height] = [500, 275];
|
||||
|
||||
// Allow for custom width and height
|
||||
if (isNumber(w)) {
|
||||
width = w;
|
||||
}
|
||||
if (isNumber(h)) {
|
||||
height = h;
|
||||
}
|
||||
|
||||
const configuration = {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: keys,
|
||||
datasets: [
|
||||
{
|
||||
data: values,
|
||||
backgroundColor: backgroundColors,
|
||||
borderColor: borderColors,
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
devicePixelRatio: 4,
|
||||
responsive: false,
|
||||
maintainAspectRatio: true,
|
||||
circumference: 180,
|
||||
rotation: -90,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
boxWidth: 20,
|
||||
font: {
|
||||
family: "'Montserrat'",
|
||||
size: 10,
|
||||
style: "normal",
|
||||
weight: "normal"
|
||||
}
|
||||
},
|
||||
position: "left"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// If we have a valid override object, merge it with the default configuration object.
|
||||
// This allows for you to override the default configuration with a custom one.
|
||||
const defaults = () => {
|
||||
if (!override || !isObject(override)) {
|
||||
return configuration;
|
||||
}
|
||||
return defaultsDeep(override, configuration);
|
||||
};
|
||||
|
||||
res.status(200).send(
|
||||
(() => {
|
||||
const canvas = createCanvas(width, height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
new Chart(ctx, defaults());
|
||||
return canvas.toDataURL();
|
||||
})()
|
||||
);
|
||||
isProcessing = true;
|
||||
await processCanvasRequest(req, res);
|
||||
processNextInQueue();
|
||||
};
|
||||
|
||||
@@ -3,24 +3,36 @@ require("dotenv").config({
|
||||
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
|
||||
});
|
||||
const logger = require("../utils/logger");
|
||||
const inlineCssTool = require("inline-css");
|
||||
//const inlineCssTool = require("inline-css");
|
||||
const juice = require("juice");
|
||||
|
||||
exports.inlinecss = (req, res) => {
|
||||
exports.inlinecss = async (req, res) => {
|
||||
//Perform request validation
|
||||
|
||||
logger.log("email-inline-css", "DEBUG", req.user.email, null, null);
|
||||
|
||||
const { html, url } = req.body;
|
||||
|
||||
inlineCssTool(html, { url: url })
|
||||
.then((inlinedHtml) => {
|
||||
res.send(inlinedHtml);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.log("email-inline-css-error", "ERROR", req.user.email, null, {
|
||||
error
|
||||
});
|
||||
|
||||
res.send(error);
|
||||
try {
|
||||
const inlinedHtml = juice(html, {
|
||||
applyAttributesTableElements: false,
|
||||
preserveMediaQueries: false,
|
||||
applyWidthAttributes: false
|
||||
});
|
||||
res.send(inlinedHtml);
|
||||
} catch (error) {
|
||||
logger.log("email-inline-css-error", "ERROR", req.user.email, null, {
|
||||
error
|
||||
});
|
||||
res.send(error.message);
|
||||
}
|
||||
|
||||
// inlineCssTool(html, { url: url })
|
||||
// .then((inlinedHtml) => {
|
||||
// res.send(inlinedHtml);
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// logger.log("email-inline-css-error", "ERROR", req.user.email, null, {
|
||||
// error
|
||||
// });
|
||||
|
||||
// });
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
const express = require("express");
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const { subscribe, unsubscribe } = require("../firebase/firebase-handler");
|
||||
const { subscribe, unsubscribe, sendNotification } = require("../firebase/firebase-handler");
|
||||
const router = express.Router();
|
||||
|
||||
router.use(validateFirebaseIdTokenMiddleware);
|
||||
|
||||
router.post("/subscribe", subscribe);
|
||||
router.post("/unsubscribe", unsubscribe);
|
||||
router.post("/sendtestnotification", sendNotification);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,10 +2,11 @@ const express = require("express");
|
||||
const router = express.Router();
|
||||
const { inlinecss } = require("../render/inlinecss");
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const validateCanvasRequestMiddleware = require("../middleware/validateCanvasRequestMiddleware");
|
||||
const { canvas } = require("../render/canvas-handler");
|
||||
|
||||
// Define the route for inline CSS rendering
|
||||
router.post("/inlinecss", validateFirebaseIdTokenMiddleware, inlinecss);
|
||||
router.post("/canvas", validateFirebaseIdTokenMiddleware, canvas);
|
||||
router.post("/canvas", [validateFirebaseIdTokenMiddleware, validateCanvasRequestMiddleware], canvas);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -11,7 +11,7 @@ const InstanceManager = require("../utils/instanceMgr").default;
|
||||
function StartStatusReporter() {
|
||||
//For ImEX Online.
|
||||
|
||||
InstanceManager({
|
||||
return InstanceManager({
|
||||
executeFunction: true,
|
||||
args: [],
|
||||
imex: () => {
|
||||
@@ -31,14 +31,14 @@ function StartStatusReporter() {
|
||||
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)
|
||||
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,
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user