Compare commits
77 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24a92e69f2 | ||
|
|
e1b00f5081 | ||
|
|
cfbf59cd48 | ||
|
|
6a09209659 | ||
|
|
cec5f6e6e7 | ||
|
|
82acaa35e1 | ||
|
|
09b8a05b5a | ||
|
|
83a1952880 | ||
|
|
e5d55f27b5 | ||
|
|
bfde72eed8 | ||
|
|
8fbd08d57f | ||
|
|
20bddb43b6 | ||
|
|
b38e0f611b | ||
|
|
c84fbcaba1 | ||
|
|
8f752d575a | ||
|
|
2fe9ae513d | ||
|
|
0001604552 | ||
|
|
5cb93b1a2c | ||
|
|
a04dcffc4c | ||
|
|
50c99f7a1e | ||
|
|
86f3179bc0 | ||
|
|
6336e7568f | ||
|
|
f0f199335c | ||
|
|
9c7c9f4b6d | ||
|
|
9001ceaed8 | ||
|
|
ab82e85c57 | ||
|
|
2effe5ef50 | ||
|
|
006a2a5dca | ||
|
|
a885bdec74 | ||
|
|
8d2bdb171b | ||
|
|
5d7eabbfa9 | ||
|
|
a2ada7d88e | ||
|
|
3a6af12446 | ||
|
|
b490ab96be | ||
|
|
ca462f51ec | ||
|
|
44721019fa | ||
|
|
8ed81e9aed | ||
|
|
15ba2a1caf | ||
|
|
aad22f2e2d | ||
|
|
7a11b18037 | ||
|
|
241322fa30 | ||
|
|
f0461270de | ||
|
|
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 |
5
.platform/hooks/predeploy/01-install-dd.sh
Normal file
5
.platform/hooks/predeploy/01-install-dd.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
DD_API_KEY=58d91898a70c6fd659f6eea768a57976 DD_SITE="us3.datadoghq.com" bash -c "$(curl -L https://install.datadoghq.com/scripts/install_script_agent7.sh)"
|
||||
|
||||
echo "Datadog agent installed."
|
||||
14
Dockerfile
14
Dockerfile
@@ -7,7 +7,6 @@ RUN dnf install -y git \
|
||||
&& dnf install -y nodejs \
|
||||
&& dnf clean all
|
||||
|
||||
|
||||
# Install dependencies required by node-canvas
|
||||
RUN dnf install -y \
|
||||
gcc \
|
||||
@@ -19,9 +18,22 @@ RUN dnf install -y \
|
||||
libpng-devel \
|
||||
make \
|
||||
python3 \
|
||||
fontconfig \
|
||||
freetype \
|
||||
python3-pip \
|
||||
wget \
|
||||
unzip \
|
||||
&& dnf clean all
|
||||
|
||||
# Install Montserrat fonts
|
||||
RUN cd /tmp \
|
||||
&& wget https://images.imex.online/fonts/montserrat.zip -O montserrat.zip \
|
||||
&& unzip montserrat.zip -d montserrat \
|
||||
&& mv montserrat/montserrat/*.ttf /usr/share/fonts \
|
||||
&& fc-cache -fv \
|
||||
&& rm -rf /tmp/montserrat /tmp/montserrat.zip \
|
||||
&& echo "Montserrat fonts installed and cached successfully."
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
@@ -379,24 +334,75 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
||||
break;
|
||||
|
||||
case "tag-added":
|
||||
// Ensure `job_conversations` is properly formatted
|
||||
const formattedJobConversations = job_conversations.map((jc) => ({
|
||||
__typename: "job_conversations",
|
||||
jobid: jc.jobid || jc.job?.id,
|
||||
conversationid: conversationId,
|
||||
job: jc.job || {
|
||||
__typename: "jobs",
|
||||
id: data.selectedJob.id,
|
||||
ro_number: data.selectedJob.ro_number,
|
||||
ownr_co_nm: data.selectedJob.ownr_co_nm,
|
||||
ownr_fn: data.selectedJob.ownr_fn,
|
||||
ownr_ln: data.selectedJob.ownr_ln
|
||||
}
|
||||
}));
|
||||
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||
fields: {
|
||||
job_conversations: (existing = []) => [...existing, ...job_conversations]
|
||||
job_conversations: (existing = []) => {
|
||||
// Ensure no duplicates based on both `conversationid` and `jobid`
|
||||
const existingLinks = new Set(
|
||||
existing.map((jc) => {
|
||||
const jobId = client.cache.readFragment({
|
||||
id: client.cache.identify(jc),
|
||||
fragment: gql`
|
||||
fragment JobConversationLinkAdded on job_conversations {
|
||||
jobid
|
||||
conversationid
|
||||
}
|
||||
`
|
||||
})?.jobid;
|
||||
return `${jobId}:${conversationId}`; // Unique identifier for a job-conversation link
|
||||
})
|
||||
);
|
||||
|
||||
const newItems = formattedJobConversations.filter((jc) => {
|
||||
const uniqueLink = `${jc.jobid}:${jc.conversationid}`;
|
||||
return !existingLinks.has(uniqueLink);
|
||||
});
|
||||
|
||||
return [...existing, ...newItems];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
try {
|
||||
const conversationCacheId = client.cache.identify({ __typename: "conversations", id: conversationId });
|
||||
|
||||
// Evict the specific cache entry for job_conversations
|
||||
client.cache.evict({
|
||||
id: conversationCacheId,
|
||||
fieldName: "job_conversations"
|
||||
});
|
||||
|
||||
// Garbage collect evicted entries
|
||||
client.cache.gc();
|
||||
|
||||
logLocal("handleConversationChanged - tag removed - Refetched conversation list after state change", {
|
||||
conversationId,
|
||||
type
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error refetching queries after conversation state change: (Tag Removed)", error);
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
logLocal("handleConversationChanged - Unhandled type", { type });
|
||||
client.cache.modify({
|
||||
@@ -421,7 +427,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, { useCallback, 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 = useCallback((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, preloadImages]);
|
||||
|
||||
// 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, preloadImages]);
|
||||
|
||||
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", {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
import { useApolloClient, useLazyQuery } from "@apollo/client";
|
||||
import { useApolloClient, useLazyQuery, useQuery } from "@apollo/client";
|
||||
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { CONVERSATION_LIST_QUERY } from "../../graphql/conversations.queries";
|
||||
import { CONVERSATION_LIST_QUERY, UNREAD_CONVERSATION_COUNT } from "../../graphql/conversations.queries";
|
||||
import { toggleChatVisible } from "../../redux/messaging/messaging.actions";
|
||||
import { selectChatVisible, selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
|
||||
import ChatConversationListComponent from "../chat-conversation-list/chat-conversation-list.component";
|
||||
@@ -38,6 +38,14 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
||||
...(pollInterval > 0 ? { pollInterval } : {})
|
||||
});
|
||||
|
||||
// Query for unread count when chat is not visible
|
||||
const { data: unreadData } = useQuery(UNREAD_CONVERSATION_COUNT, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
skip: chatVisible, // Skip when chat is visible
|
||||
...(pollInterval > 0 ? { pollInterval } : {})
|
||||
});
|
||||
|
||||
// Socket connection status
|
||||
useEffect(() => {
|
||||
const handleSocketStatus = () => {
|
||||
@@ -77,23 +85,29 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
||||
|
||||
// Get unread count from the cache
|
||||
const unreadCount = (() => {
|
||||
try {
|
||||
const cachedData = client.readQuery({
|
||||
query: CONVERSATION_LIST_QUERY,
|
||||
variables: { offset: 0 }
|
||||
});
|
||||
if (chatVisible) {
|
||||
try {
|
||||
const cachedData = client.readQuery({
|
||||
query: CONVERSATION_LIST_QUERY,
|
||||
variables: { offset: 0 }
|
||||
});
|
||||
|
||||
if (!cachedData?.conversations) return 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
|
||||
// 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
|
||||
}
|
||||
} else if (unreadData?.messages_aggregate?.aggregate?.count) {
|
||||
// Use the unread count from the query result
|
||||
return unreadData.messages_aggregate.aggregate.count;
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
|
||||
return (
|
||||
@@ -108,7 +122,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,40 @@ 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;
|
||||
socket.emit("conversation-modified", {
|
||||
conversationId: conversation.id,
|
||||
bodyshopId: bodyshop.id,
|
||||
type: "tag-added",
|
||||
selectedJob,
|
||||
job_conversations: [
|
||||
{
|
||||
__typename: "job_conversations",
|
||||
jobid: selectedJob.id,
|
||||
conversationid: conversation.id,
|
||||
job: {
|
||||
__typename: "jobs",
|
||||
id: selectedJob.id,
|
||||
ro_number: selectedJob.ro_number,
|
||||
ownr_co_nm: selectedJob.ownr_co_nm,
|
||||
ownr_fn: selectedJob.ownr_fn,
|
||||
ownr_ln: selectedJob.ownr_ln
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
@@ -85,9 +92,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
|
||||
|
||||
@@ -25,6 +25,9 @@ export default function OwnerDetailFormComponent({ form, loading }) {
|
||||
<Form.Item label={t("owners.fields.ownr_co_nm")} name="ownr_co_nm">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("owners.fields.accountingid")} name="accountingid">
|
||||
<Input disabled/>
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<LayoutFormRow header={t("owners.forms.address")}>
|
||||
<Form.Item label={t("owners.fields.ownr_addr1")} name="ownr_addr1">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,6 +48,7 @@ export const QUERY_OWNER_BY_ID = gql`
|
||||
query QUERY_OWNER_BY_ID($id: uuid!) {
|
||||
owners_by_pk(id: $id) {
|
||||
id
|
||||
accountingid
|
||||
allow_text_message
|
||||
ownr_addr1
|
||||
ownr_addr2
|
||||
|
||||
@@ -2394,6 +2394,7 @@
|
||||
"selectexistingornew": "Select an existing owner record or create a new one. "
|
||||
},
|
||||
"fields": {
|
||||
"accountingid": "Accounting ID",
|
||||
"address": "Address",
|
||||
"allow_text_message": "Permission to Text?",
|
||||
"name": "Name",
|
||||
@@ -3057,6 +3058,7 @@
|
||||
"production_not_production_status": "Production not in Production Status",
|
||||
"production_over_time": "Production Level over Time",
|
||||
"psr_by_make": "Percent of Sales by Vehicle Make",
|
||||
"purchase_return_ratio_excel": "Purchase & Return Ratio - Excel",
|
||||
"purchase_return_ratio_grouped_by_vendor_detail": "Purchase & Return Ratio by Vendor (Detail)",
|
||||
"purchase_return_ratio_grouped_by_vendor_summary": "Purchase & Return Ratio by Vendor (Summary)",
|
||||
"purchases_by_cost_center_detail": "Purchases by Cost Center (Detail)",
|
||||
@@ -3082,6 +3084,7 @@
|
||||
"timetickets": "Time Tickets",
|
||||
"timetickets_employee": "Employee Time Tickets",
|
||||
"timetickets_summary": "Time Tickets Summary",
|
||||
"total_loss_jobs": "Jobs Marked as Total Loss",
|
||||
"unclaimed_hrs": "Unflagged Hours",
|
||||
"void_ros": "Void ROs",
|
||||
"work_in_progress_committed_labour": "Work in Progress - Committed Labor",
|
||||
|
||||
@@ -2394,6 +2394,7 @@
|
||||
"selectexistingornew": ""
|
||||
},
|
||||
"fields": {
|
||||
"accountingid": "",
|
||||
"address": "Dirección",
|
||||
"allow_text_message": "Permiso de texto?",
|
||||
"name": "Nombre",
|
||||
@@ -3057,6 +3058,7 @@
|
||||
"production_not_production_status": "",
|
||||
"production_over_time": "",
|
||||
"psr_by_make": "",
|
||||
"purchase_return_ratio_excel": "",
|
||||
"purchase_return_ratio_grouped_by_vendor_detail": "",
|
||||
"purchase_return_ratio_grouped_by_vendor_summary": "",
|
||||
"purchases_by_cost_center_detail": "",
|
||||
@@ -3082,6 +3084,7 @@
|
||||
"timetickets": "",
|
||||
"timetickets_employee": "",
|
||||
"timetickets_summary": "",
|
||||
"total_loss_jobs": "",
|
||||
"unclaimed_hrs": "",
|
||||
"void_ros": "",
|
||||
"work_in_progress_committed_labour": "",
|
||||
|
||||
@@ -2394,6 +2394,7 @@
|
||||
"selectexistingornew": ""
|
||||
},
|
||||
"fields": {
|
||||
"accountingid": "",
|
||||
"address": "Adresse",
|
||||
"allow_text_message": "Autorisation de texte?",
|
||||
"name": "Prénom",
|
||||
@@ -3057,6 +3058,7 @@
|
||||
"production_not_production_status": "",
|
||||
"production_over_time": "",
|
||||
"psr_by_make": "",
|
||||
"purchase_return_ratio_excel": "",
|
||||
"purchase_return_ratio_grouped_by_vendor_detail": "",
|
||||
"purchase_return_ratio_grouped_by_vendor_summary": "",
|
||||
"purchases_by_cost_center_detail": "",
|
||||
@@ -3082,6 +3084,7 @@
|
||||
"timetickets": "",
|
||||
"timetickets_employee": "",
|
||||
"timetickets_summary": "",
|
||||
"total_loss_jobs": "",
|
||||
"unclaimed_hrs": "",
|
||||
"void_ros": "",
|
||||
"work_in_progress_committed_labour": "",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { setContext } from "@apollo/client/link/context";
|
||||
import { HttpLink } from "@apollo/client/link/http"; //"apollo-link-http";
|
||||
import { RetryLink } from "@apollo/client/link/retry";
|
||||
import { WebSocketLink } from "@apollo/client/link/ws";
|
||||
import { getMainDefinition, offsetLimitPagination } from "@apollo/client/utilities";
|
||||
import { getMainDefinition } from "@apollo/client/utilities";
|
||||
//import { split } from "apollo-link";
|
||||
import apolloLogger from "apollo-link-logger";
|
||||
//import axios from "axios";
|
||||
@@ -143,49 +143,7 @@ middlewares.push(
|
||||
new SentryLink().concat(roundTripLink.concat(retryLink.concat(errorLink.concat(authLink.concat(link)))))
|
||||
);
|
||||
|
||||
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)));
|
||||
|
||||
// Merge incoming messages, avoiding duplicates
|
||||
const merged = [...existing];
|
||||
incoming.forEach((message) => {
|
||||
if (!existingIds.has(readField("id", message))) {
|
||||
merged.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const cache = new InMemoryCache({});
|
||||
const client = new ApolloClient({
|
||||
link: ApolloLink.from(middlewares),
|
||||
cache,
|
||||
|
||||
@@ -2184,6 +2184,30 @@ export const TemplateList = (type, context) => {
|
||||
},
|
||||
group: "payroll",
|
||||
adp_payroll: true
|
||||
},
|
||||
purchase_return_ratio_excel: {
|
||||
title: i18n.t("reportcenter.templates.purchase_return_ratio_excel"),
|
||||
subject: i18n.t("reportcenter.templates.purchase_return_ratio_excel"),
|
||||
key: "purchase_return_ratio_excel",
|
||||
//idtype: "vendor",
|
||||
reporttype: "excel",
|
||||
disabled: false,
|
||||
rangeFilter: {
|
||||
object: i18n.t("reportcenter.labels.objects.bills"),
|
||||
field: i18n.t("bills.fields.date")
|
||||
},
|
||||
group: "purchases"
|
||||
},
|
||||
total_loss_jobs: {
|
||||
title: i18n.t("reportcenter.templates.total_loss_jobs"),
|
||||
subject: i18n.t("reportcenter.templates.total_loss_jobs"),
|
||||
key: "total_loss_jobs",
|
||||
disabled: false,
|
||||
rangeFilter: {
|
||||
object: i18n.t("reportcenter.labels.objects.jobs"),
|
||||
field: i18n.t("jobs.fields.date_open")
|
||||
},
|
||||
group: "jobs"
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
|
||||
859
package-lock.json
generated
859
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@
|
||||
"cors": "2.8.5",
|
||||
"crisp-status-reporter": "^1.2.2",
|
||||
"csrf": "^3.1.0",
|
||||
"dd-trace": "^5.28.0",
|
||||
"dinero.js": "^1.9.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.1",
|
||||
@@ -51,6 +52,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",
|
||||
@@ -62,6 +64,7 @@
|
||||
"recursive-diff": "^1.0.9",
|
||||
"redis": "^4.7.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"skia-canvas": "^2.0.0",
|
||||
"soap": "^1.1.6",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-adapter": "^2.5.5",
|
||||
|
||||
49
server.js
49
server.js
@@ -4,6 +4,14 @@ require("dotenv").config({
|
||||
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV) {
|
||||
const tracer = require("dd-trace").init({
|
||||
profiling: true,
|
||||
env: process.env.NODE_ENV,
|
||||
service: "bodyshop-api"
|
||||
});
|
||||
}
|
||||
|
||||
const cors = require("cors");
|
||||
const http = require("http");
|
||||
const Redis = require("ioredis");
|
||||
@@ -24,6 +32,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 +308,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 +334,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);
|
||||
}
|
||||
|
||||
@@ -328,6 +328,7 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
|
||||
PostalCode: job.ownr_zip,
|
||||
CountrySubDivisionCode: job.ownr_st
|
||||
},
|
||||
...(job.ownr_ea ? { PrimaryEmailAddr: { Address: job.ownr_ea.trim() } } : {}),
|
||||
...(isThreeTier
|
||||
? {
|
||||
Job: true,
|
||||
@@ -395,7 +396,7 @@ async function InsertJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
|
||||
PostalCode: job.ownr_zip,
|
||||
CountrySubDivisionCode: job.ownr_st
|
||||
},
|
||||
|
||||
...(job.ownr_ea ? { PrimaryEmailAddr: { Address: job.ownr_ea.trim() } } : {}),
|
||||
Job: true,
|
||||
ParentRef: {
|
||||
value: parentTierRef.Id
|
||||
@@ -556,7 +557,8 @@ async function InsertInvoice(oauthClient, qbo_realmId, req, job, bodyshop, paren
|
||||
Line3: `${job.ownr_city || ""}, ${job.ownr_st || ""} ${job.ownr_zip || ""}`.trim(),
|
||||
Line2: job.ownr_addr1 || "",
|
||||
Line1: `${job.ownr_fn || ""} ${job.ownr_ln || ""} ${job.ownr_co_nm || ""}`
|
||||
}
|
||||
},
|
||||
...(job.ownr_ea ? { BillEmail: { Address: job.ownr_ea.trim() } } : {})
|
||||
};
|
||||
|
||||
logger.log("qbo-receivable-objectlog", "DEBUG", req.user.email, job.id, {
|
||||
@@ -673,7 +675,8 @@ async function InsertInvoiceMultiPayerInvoice(
|
||||
Line3: `${job.ownr_city || ""}, ${job.ownr_st || ""} ${job.ownr_zip || ""}`.trim(),
|
||||
Line2: job.ownr_addr1 || "",
|
||||
Line1: `${job.ownr_fn || ""} ${job.ownr_ln || ""} ${job.ownr_co_nm || ""}`
|
||||
}
|
||||
},
|
||||
...(job.ownr_ea ? { BillEmail: { Address: job.ownr_ea.trim() } } : {})
|
||||
};
|
||||
|
||||
logger.log("qbo-receivable-objectlog", "DEBUG", req.user.email, job.id, {
|
||||
|
||||
@@ -16,7 +16,7 @@ const { sendServerEmail } = require("../email/sendemail");
|
||||
const DineroFormat = "0,0.00";
|
||||
const DateFormat = "MM/DD/YYYY";
|
||||
|
||||
const kaizenShopsIDs = ["SUMMIT", "STRATHMORE", "SUNRIDGE", "SHAW"];
|
||||
const kaizenShopsIDs = ["SUMMIT", "STRATHMORE", "SUNRIDGE", "SHAW", "DEERFOOT"];
|
||||
|
||||
const ftpSetup = {
|
||||
host: process.env.KAIZEN_HOST,
|
||||
|
||||
@@ -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.
|
||||
|
||||
32
server/middleware/validateCanvasInputMiddleware.js
Normal file
32
server/middleware/validateCanvasInputMiddleware.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const { isObject } = require("lodash");
|
||||
|
||||
const validateCanvasInputMiddleware = (req, res, next) => {
|
||||
const { values, keys, override, w, h } = req.body;
|
||||
|
||||
if (!Array.isArray(values) || !Array.isArray(keys)) {
|
||||
return res.status(400).send("Invalid input: 'values' and 'keys' must be arrays.");
|
||||
}
|
||||
|
||||
if (values.some((value) => typeof value !== "number")) {
|
||||
return res.status(400).send("Invalid input: 'values' must be an array of numbers.");
|
||||
}
|
||||
|
||||
if (keys.some((key) => typeof key !== "string")) {
|
||||
return res.status(400).send("Invalid input: 'keys' must be an array of strings.");
|
||||
}
|
||||
|
||||
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(); // Proceed to the next middleware or route handler
|
||||
};
|
||||
|
||||
module.exports = validateCanvasInputMiddleware;
|
||||
@@ -1,43 +1,31 @@
|
||||
const { createCanvas } = require("canvas");
|
||||
const { Canvas, FontLibrary } = require("skia-canvas");
|
||||
const Chart = require("chart.js/auto");
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
const { backgroundColors, borderColors } = require("./canvas-colors");
|
||||
const { isObject, defaultsDeep, isNumber } = require("lodash");
|
||||
const { defaultsDeep, isNumber } = require("lodash");
|
||||
|
||||
exports.canvastest = function (req, res) {
|
||||
//console.log("Incoming test request.", req);
|
||||
res.status(200).send("OK");
|
||||
};
|
||||
const CANVAS_QUEUE_LIMIT = 100;
|
||||
|
||||
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");
|
||||
return;
|
||||
}
|
||||
let isProcessing = false;
|
||||
const requestQueue = [];
|
||||
|
||||
// Override must be an object if it exists
|
||||
if (override && !isObject(override)) {
|
||||
res.status(400).send("Override must be an object");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
FontLibrary.use("Montserrat", [
|
||||
"/usr/share/fonts/Montserrat-Regular.ttf",
|
||||
"/usr/share/fonts/Montserrat-Bold.ttf",
|
||||
"/usr/share/fonts/Montserrat-Italic.ttf"
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error loading fonts Skia Canvas Fonts, please be sure to install Montserrat font package",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
// Utility to create a chart configuration
|
||||
const getChartConfiguration = (keys, values, override) => {
|
||||
const defaultConfiguration = {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: keys,
|
||||
@@ -53,6 +41,7 @@ exports.canvas = function (req, res) {
|
||||
options: {
|
||||
devicePixelRatio: 4,
|
||||
responsive: false,
|
||||
animation: false,
|
||||
maintainAspectRatio: true,
|
||||
circumference: 180,
|
||||
rotation: -90,
|
||||
@@ -73,21 +62,88 @@ exports.canvas = function (req, res) {
|
||||
}
|
||||
};
|
||||
|
||||
// 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();
|
||||
})()
|
||||
);
|
||||
return defaultsDeep(override || {}, defaultConfiguration);
|
||||
};
|
||||
|
||||
const processCanvasRequest = async (req, res, isSkia = false) => {
|
||||
const { logger } = req;
|
||||
const { w, h, values, keys, override } = req.body;
|
||||
|
||||
logger.log("inbound-canvas-creation", "debug", "jsr", null, { w, h, values, keys, override });
|
||||
|
||||
// Default width and height
|
||||
const width = isNumber(w) && w > 0 ? w : 500;
|
||||
const height = isNumber(h) && h > 0 ? h : 275;
|
||||
|
||||
const configuration = getChartConfiguration(keys, values, override);
|
||||
|
||||
// Placeholders to allow fine control over GAC
|
||||
let canvas = null;
|
||||
let ctx = null;
|
||||
let chart = null;
|
||||
let chartImage = null;
|
||||
|
||||
try {
|
||||
// Create the canvas
|
||||
canvas = isSkia ? new Canvas(width, height) : createCanvas(width, height);
|
||||
ctx = canvas.getContext("2d");
|
||||
|
||||
// Render the chart
|
||||
chart = new Chart(ctx, configuration);
|
||||
|
||||
// Generate and send the image
|
||||
chartImage = isSkia ? (await canvas.toBuffer("image/png")).toString("base64") : canvas.toDataURL();
|
||||
|
||||
res.status(200).send(isSkia ? `data:image/png;base64,${chartImage}` : chartImage);
|
||||
} catch (error) {
|
||||
// Log the error and send the response
|
||||
logger.log("canvas-error", "error", "jsr", null, { error: error.message });
|
||||
res.status(500).send("Failed to generate canvas.");
|
||||
} finally {
|
||||
// Cleanup resources
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
ctx = null; // Explicitly nullify for garbage collection
|
||||
canvas = null; // Explicitly nullify for garbage collection
|
||||
chartImage = null;
|
||||
}
|
||||
};
|
||||
|
||||
const enqueueRequest = (req, res, isSkia) => {
|
||||
if (requestQueue.length >= CANVAS_QUEUE_LIMIT) {
|
||||
res.status(503).send("Server is busy. Please try again later.");
|
||||
return false;
|
||||
}
|
||||
requestQueue.push({ req, res, isSkia });
|
||||
req.logger.log("inbound-canvas-creation-queue", "debug", "jsr", null, { queue: requestQueue.length });
|
||||
return true;
|
||||
};
|
||||
|
||||
const processNextInQueue = async () => {
|
||||
while (requestQueue.length > 0) {
|
||||
const { req, res, isSkia } = requestQueue.shift();
|
||||
try {
|
||||
await processCanvasRequest(req, res, isSkia);
|
||||
} catch (err) {
|
||||
console.error("canvas-queue-error", "error", "jsr", null, { error: err.message });
|
||||
}
|
||||
}
|
||||
isProcessing = false;
|
||||
};
|
||||
|
||||
exports.canvastest = function (req, res) {
|
||||
res.status(200).send("OK");
|
||||
};
|
||||
|
||||
exports.canvas = async (req, res) => {
|
||||
if (isProcessing || !enqueueRequest(req, res, false)) return;
|
||||
isProcessing = true;
|
||||
processNextInQueue().catch((err) => console.error("canvas-processing-error", { error: err.message }));
|
||||
};
|
||||
|
||||
exports.canvasSkia = async (req, res) => {
|
||||
if (isProcessing || !enqueueRequest(req, res, true)) return;
|
||||
isProcessing = true;
|
||||
processNextInQueue().catch((err) => console.error("canvas-processing-error", { error: err.message }));
|
||||
};
|
||||
|
||||
@@ -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,12 @@ const express = require("express");
|
||||
const router = express.Router();
|
||||
const { inlinecss } = require("../render/inlinecss");
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const { canvas } = require("../render/canvas-handler");
|
||||
const { canvas, canvasSkia } = require("../render/canvas-handler");
|
||||
const validateCanvasInputMiddleware = require("../middleware/validateCanvasInputMiddleware");
|
||||
|
||||
// Define the route for inline CSS rendering
|
||||
router.post("/inlinecss", validateFirebaseIdTokenMiddleware, inlinecss);
|
||||
router.post("/canvas", validateFirebaseIdTokenMiddleware, canvas);
|
||||
router.post("/canvas", [validateFirebaseIdTokenMiddleware, validateCanvasInputMiddleware], canvas);
|
||||
router.post("/canvas-skia", [validateFirebaseIdTokenMiddleware, validateCanvasInputMiddleware], canvasSkia);
|
||||
|
||||
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