Compare commits
21 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1e1d5e82c | ||
|
|
5ae2e33596 | ||
|
|
e11260e8fc | ||
|
|
dfd88308e0 | ||
|
|
33579c3e6a | ||
|
|
0b9b3c027f | ||
|
|
154f9cdfe6 | ||
|
|
5b8c7d922c | ||
|
|
d20347d5dc | ||
|
|
68c4a1efd7 | ||
|
|
3e6d6fdbd1 | ||
|
|
302fd58a56 | ||
|
|
31cfdf9ea3 | ||
|
|
8b39b7c7be | ||
|
|
038aa82087 | ||
|
|
521955089f | ||
|
|
4afff893c0 | ||
|
|
cc934fe333 | ||
|
|
8ded028197 | ||
|
|
c42a0139fc | ||
|
|
02974e6e4b |
@@ -1,116 +1,96 @@
|
||||
// index.js
|
||||
|
||||
import express from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
import {simpleParser} from 'mailparser';
|
||||
import express from "express";
|
||||
import fetch from "node-fetch";
|
||||
import { simpleParser } from "mailparser";
|
||||
|
||||
const app = express();
|
||||
const PORT = 3334;
|
||||
|
||||
app.get('/', async (req, res) => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:4566/_aws/ses');
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
const data = await response.json();
|
||||
const messagesHtml = await parseMessages(data.messages);
|
||||
res.send(renderHtml(messagesHtml));
|
||||
} catch (error) {
|
||||
console.error('Error fetching messages:', error);
|
||||
res.status(500).send('Error fetching messages');
|
||||
app.get("/", async (req, res) => {
|
||||
try {
|
||||
const response = await fetch("http://localhost:4566/_aws/ses");
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
const data = await response.json();
|
||||
const messagesHtml = await parseMessages(data.messages);
|
||||
res.send(renderHtml(messagesHtml));
|
||||
} catch (error) {
|
||||
console.error("Error fetching messages:", error);
|
||||
res.status(500).send("Error fetching messages");
|
||||
}
|
||||
});
|
||||
|
||||
async function parseMessages(messages) {
|
||||
const parsedMessages = await Promise.all(
|
||||
messages.map(async (message, index) => {
|
||||
try {
|
||||
const parsed = await simpleParser(message.RawData);
|
||||
return `
|
||||
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: lightgray">
|
||||
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: white">
|
||||
<div class="mb-2">
|
||||
<span class="font-bold text-lg">Message ${index + 1}</span>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="font-semibold">From:</span> ${message.Source}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="font-semibold">Region:</span> ${message.Region}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="font-semibold">Timestamp:</span> ${message.Timestamp}
|
||||
</div>
|
||||
</div>
|
||||
<div class="prose">
|
||||
${parsed.html || parsed.textAsHtml || 'No HTML content available'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('Error parsing email:', error);
|
||||
return `
|
||||
<div class="bg-white shadow-md rounded-lg p-4 mb-6">
|
||||
<div class="mb-2">
|
||||
<span class="font-bold text-lg">Message ${index + 1}</span>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="font-semibold">From:</span> ${message.Source}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="font-semibold">Region:</span> ${message.Region}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="font-semibold">Timestamp:</span> ${message.Timestamp}
|
||||
</div>
|
||||
<div class="text-red-500">
|
||||
Error parsing email content
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
})
|
||||
);
|
||||
return parsedMessages.join('');
|
||||
const parsedMessages = await Promise.all(
|
||||
messages.map(async (message, index) => {
|
||||
try {
|
||||
const parsed = await simpleParser(message.RawData);
|
||||
return `
|
||||
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: lightgray">
|
||||
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: white">
|
||||
<div class="mb-2"><span class="font-bold text-lg">Message ${index + 1}</span></div>
|
||||
<div class="mb-2"><span class="font-semibold">From:</span> ${message.Source}</div>
|
||||
<div class="mb-2"><span class="font-semibold">To:</span> ${parsed.to.text || "No To Address"}</div>
|
||||
<div class="mb-2"><span class="font-semibold">Subject:</span> ${parsed.subject || "No Subject"}</div>
|
||||
<div class="mb-2"><span class="font-semibold">Region:</span> ${message.Region}</div>
|
||||
<div class="mb-2"><span class="font-semibold">Timestamp:</span> ${message.Timestamp}</div>
|
||||
</div>
|
||||
<div class="prose">${parsed.html || parsed.textAsHtml || "No HTML content available"}</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error("Error parsing email:", error);
|
||||
return `
|
||||
<div class="bg-white shadow-md rounded-lg p-4 mb-6">
|
||||
<div class="mb-2"><span class="font-bold text-lg">Message ${index + 1}</span></div>
|
||||
<div class="mb-2"><span class="font-semibold">From:</span> ${message.Source}</div>
|
||||
<div class="mb-2"><span class="font-semibold">Region:</span> ${message.Region}</div>
|
||||
<div class="mb-2"><span class="font-semibold">Timestamp:</span> ${message.Timestamp}</div>
|
||||
<div class="text-red-500">Error parsing email content</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
})
|
||||
);
|
||||
return parsedMessages.join("");
|
||||
}
|
||||
|
||||
function renderHtml(messagesHtml) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Email Messages Viewer</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #f3f4f6;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.prose {
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container bg-white shadow-lg rounded-lg p-6">
|
||||
<h1 class="text-2xl font-bold text-center mb-6">Email Messages Viewer</h1>
|
||||
<div id="messages-container">
|
||||
${messagesHtml}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Email Messages Viewer</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #f3f4f6;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.prose {
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container bg-white shadow-lg rounded-lg p-6">
|
||||
<h1 class="text-2xl font-bold text-center mb-6">Email Messages Viewer</h1>
|
||||
<div id="messages-container">${messagesHtml}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on http://localhost:${PORT}`);
|
||||
});
|
||||
console.log(`Server is running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
42
_reference/localEmailViewer/package-lock.json
generated
42
_reference/localEmailViewer/package-lock.json
generated
@@ -10,7 +10,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"mailparser": "^3.7.2",
|
||||
"mailparser": "^3.7.4",
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
},
|
||||
@@ -634,9 +634,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"version": "5.3.6",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.6.tgz",
|
||||
"integrity": "sha512-j9mBC7eiqi6fgBPAGvKCXJKJSIASanYF4EeA4iBzSG0HxQxmXnR3KbyWqTn4CwsKSebqCv2f5XZfAO6sKzgvwA==",
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
|
||||
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
@@ -661,31 +661,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.2.tgz",
|
||||
"integrity": "sha512-iI0p2TCcIodR1qGiRoDBBwboSSff50vQAWytM5JRggLfABa4hHYCf3YVujtuzV454xrOP352VsAPIzviqMTo4Q==",
|
||||
"version": "3.7.4",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.4.tgz",
|
||||
"integrity": "sha512-Beh4yyR4jLq3CZZ32asajByrXnW8dLyKCAQD3WvtTiBnMtFWhxO+wa93F6sJNjDmfjxXs4NRNjw3XAGLqZR3Vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"iconv-lite": "0.6.3",
|
||||
"libmime": "5.3.6",
|
||||
"libmime": "5.3.7",
|
||||
"linkify-it": "5.0.0",
|
||||
"mailsplit": "5.4.2",
|
||||
"nodemailer": "6.9.16",
|
||||
"mailsplit": "5.4.5",
|
||||
"nodemailer": "7.0.4",
|
||||
"punycode.js": "2.3.1",
|
||||
"tlds": "1.255.0"
|
||||
"tlds": "1.259.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailsplit": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.2.tgz",
|
||||
"integrity": "sha512-4cczG/3Iu3pyl8JgQ76dKkisurZTmxMrA4dj/e8d2jKYcFTZ7MxOzg1gTioTDMPuFXwTrVuN/gxhkrO7wLg7qA==",
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.5.tgz",
|
||||
"integrity": "sha512-oMfhmvclR689IIaQmIcR5nODnZRRVwAKtqFT407TIvmhX2OLUBnshUTcxzQBt3+96sZVDud9NfSe1NxAkUNXEQ==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.6",
|
||||
"libmime": "5.3.7",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
@@ -793,9 +793,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.9.16",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz",
|
||||
"integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==",
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.4.tgz",
|
||||
"integrity": "sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -1114,9 +1114,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tlds": {
|
||||
"version": "1.255.0",
|
||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.255.0.tgz",
|
||||
"integrity": "sha512-tcwMRIioTcF/FcxLev8MJWxCp+GUALRhFEqbDoZrnowmKSGqPrl5pqS+Sut2m8BgJ6S4FExCSSpGffZ0Tks6Aw==",
|
||||
"version": "1.259.0",
|
||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz",
|
||||
"integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tlds": "bin.js"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"mailparser": "^3.7.2",
|
||||
"mailparser": "^3.7.4",
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import InstanceRenderMgr from "../utils/instanceRenderMgr";
|
||||
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
|
||||
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
|
||||
import SocketProvider from "../contexts/SocketIO/socketProvider.jsx";
|
||||
import SoundWrapper from "./SoundWrapper.jsx";
|
||||
|
||||
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
|
||||
const ManagePage = lazy(() => import("../pages/manage/manage.page.container"));
|
||||
@@ -72,9 +73,6 @@ export function App({
|
||||
setIsPartsEntry(isParts);
|
||||
}, [setIsPartsEntry]);
|
||||
|
||||
//const b = Grid.useBreakpoint();
|
||||
// console.log("Breakpoints:", b);
|
||||
|
||||
// Associate event listeners, memoize to prevent multiple listeners being added
|
||||
useEffect(() => {
|
||||
const offlineListener = () => {
|
||||
@@ -164,85 +162,87 @@ export function App({
|
||||
/>
|
||||
|
||||
<NotificationProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<LandingPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/signin"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SignInPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/resetpassword"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<ResetPassword />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/csi/:surveyId"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<CsiPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/disclaimer"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<DisclaimerPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/manage/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||
<SoundWrapper bodyshop={bodyshop}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<LandingPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/signin"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SignInPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/resetpassword"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<ResetPassword />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/csi/:surveyId"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<CsiPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/disclaimer"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<DisclaimerPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/manage/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<ManagePage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/tech/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<TechPageContainer />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/parts/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<ManagePage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/tech/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<TechPageContainer />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/parts/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<SimplifiedPartsPageContainer />} />
|
||||
</Route>
|
||||
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
|
||||
<Route path="*" element={<DocumentEditorContainer />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<SimplifiedPartsPageContainer />} />
|
||||
</Route>
|
||||
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
|
||||
<Route path="*" element={<DocumentEditorContainer />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</SoundWrapper>
|
||||
</NotificationProvider>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
43
client/src/App/SoundWrapper.jsx
Normal file
43
client/src/App/SoundWrapper.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNotification } from "../contexts/Notifications/notificationContext.jsx";
|
||||
import { initNewMessageSound, unlockAudio } from "./../utils/soundManager";
|
||||
import { initSingleTabAudioLeader } from "../utils/singleTabAudioLeader";
|
||||
|
||||
export default function SoundWrapper({ children, bodyshop }) {
|
||||
const { t } = useTranslation();
|
||||
const notification = useNotification();
|
||||
|
||||
useEffect(() => {
|
||||
if (!bodyshop?.id) return;
|
||||
|
||||
// 1) Init single-tab leader election (only one tab should play sounds), scoped by bodyshopId
|
||||
const cleanupLeader = initSingleTabAudioLeader(bodyshop.id);
|
||||
|
||||
// 2) Initialize base audio
|
||||
initNewMessageSound("https://images.imex.online/app/messageTone.wav", 0.7);
|
||||
|
||||
// 3) Show a one-time prompt when autoplay blocks first play
|
||||
const onNeedsUnlock = () => {
|
||||
notification.info({
|
||||
description: t("audio.manager.description"),
|
||||
duration: 3
|
||||
});
|
||||
};
|
||||
window.addEventListener("sound-needs-unlock", onNeedsUnlock);
|
||||
|
||||
// 4) Proactively unlock on first gesture (once per session)
|
||||
const gesture = () => unlockAudio(bodyshop.id);
|
||||
window.addEventListener("click", gesture, { once: true, passive: true });
|
||||
window.addEventListener("touchstart", gesture, { once: true, passive: true });
|
||||
window.addEventListener("keydown", gesture, { once: true });
|
||||
|
||||
return () => {
|
||||
cleanupLeader();
|
||||
window.removeEventListener("sound-needs-unlock", onNeedsUnlock);
|
||||
// gesture listeners were added with {once:true}
|
||||
};
|
||||
}, [notification, t, bodyshop?.id]); // include bodyshop.id so this runs when org changes
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -9,13 +9,13 @@ import "./chat-affix.styles.scss";
|
||||
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
|
||||
export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
||||
export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
|
||||
const { t } = useTranslation();
|
||||
const client = useApolloClient();
|
||||
const { socket } = useSocket();
|
||||
|
||||
useEffect(() => {
|
||||
if (!bodyshop || !bodyshop.messagingservicesid) return;
|
||||
if (!bodyshop?.messagingservicesid) return;
|
||||
|
||||
async function SubscribeToTopicForFCMNotification() {
|
||||
try {
|
||||
@@ -35,8 +35,8 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
||||
SubscribeToTopicForFCMNotification();
|
||||
|
||||
// Register WebSocket handlers
|
||||
if (socket && socket.connected) {
|
||||
registerMessagingHandlers({ socket, client });
|
||||
if (socket?.connected) {
|
||||
registerMessagingHandlers({ socket, client, currentUser, bodyshop, t });
|
||||
|
||||
return () => {
|
||||
unregisterMessagingHandlers({ socket });
|
||||
@@ -44,11 +44,11 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
||||
}
|
||||
}, [bodyshop, socket, t, client]);
|
||||
|
||||
if (!bodyshop || !bodyshop.messagingservicesid) return <></>;
|
||||
if (!bodyshop?.messagingservicesid) return <></>;
|
||||
|
||||
return (
|
||||
<div className={`chat-affix ${chatVisible ? "chat-affix-open" : ""}`}>
|
||||
{bodyshop && bodyshop.messagingservicesid ? <ChatPopupComponent /> : null}
|
||||
{bodyshop?.messagingservicesid ? <ChatPopupComponent /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
|
||||
import { gql } from "@apollo/client";
|
||||
|
||||
import { playNewMessageSound } from "../../utils/soundManager.js";
|
||||
import { isLeaderTab } from "../../utils/singleTabAudioLeader";
|
||||
|
||||
import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
|
||||
import { QUERY_ACTIVE_ASSOCIATION_SOUND } from "../../graphql/user.queries";
|
||||
|
||||
const logLocal = (message, ...args) => {
|
||||
if (import.meta.env.VITE_APP_IS_TEST || !import.meta.env.PROD) {
|
||||
console.log(`==================== ${message} ====================`);
|
||||
@@ -26,16 +31,48 @@ const enrichConversation = (conversation, isOutbound) => ({
|
||||
__typename: "conversations"
|
||||
});
|
||||
|
||||
export const registerMessagingHandlers = ({ socket, client }) => {
|
||||
// Can be uncommonted to test the playback of the notification sound
|
||||
// window.testTone = () => {
|
||||
// const notificationSound = new Audio(newMessageSound);
|
||||
// notificationSound.play().catch((error) => {
|
||||
// console.error("Error playing notification sound:", error);
|
||||
// });
|
||||
// };
|
||||
|
||||
export const registerMessagingHandlers = ({ socket, client, currentUser, bodyshop }) => {
|
||||
if (!(socket && client)) return;
|
||||
|
||||
const handleNewMessageSummary = async (message) => {
|
||||
const { conversationId, newConversation, existingConversation, isoutbound } = message;
|
||||
|
||||
// True only when DB value is strictly true; falls back to true on cache miss
|
||||
const isNewMessageSoundEnabled = (client) => {
|
||||
try {
|
||||
const email = currentUser?.email;
|
||||
if (!email) return true; // default allow if we can't resolve user
|
||||
const res = client.readQuery({
|
||||
query: QUERY_ACTIVE_ASSOCIATION_SOUND,
|
||||
variables: { email }
|
||||
});
|
||||
const flag = res?.associations?.[0]?.new_message_sound;
|
||||
return flag === true; // strictly true => enabled
|
||||
} catch {
|
||||
// If the query hasn't been seeded in cache yet, default ON
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
logLocal("handleNewMessageSummary - Start", { message, isNew: !existingConversation });
|
||||
|
||||
const queryVariables = { offset: 0 };
|
||||
|
||||
if (!isoutbound) {
|
||||
// Play notification sound for new inbound message (scoped to bodyshop)
|
||||
if (isLeaderTab(bodyshop.id) && isNewMessageSoundEnabled(client)) {
|
||||
playNewMessageSound(bodyshop.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!existingConversation && conversationId) {
|
||||
// Attempt to read from the cache to determine if this is actually a new conversation
|
||||
try {
|
||||
@@ -291,8 +328,6 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
||||
|
||||
case "conversation-unarchived":
|
||||
case "conversation-archived":
|
||||
// Would like to someday figure out how to get this working without refetch queries,
|
||||
// But I have but a solid 4 hours into it, and there are just too many weird occurrences
|
||||
try {
|
||||
const listQueryVariables = { offset: 0 };
|
||||
const detailsQueryVariables = { conversationId };
|
||||
@@ -328,7 +363,8 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
||||
}
|
||||
break;
|
||||
|
||||
case "tag-added": { // Ensure `job_conversations` is properly formatted
|
||||
case "tag-added": {
|
||||
// Ensure `job_conversations` is properly formatted
|
||||
const formattedJobConversations = job_conversations.map((jc) => ({
|
||||
__typename: "job_conversations",
|
||||
jobid: jc.jobid || jc.job?.id,
|
||||
|
||||
@@ -2,11 +2,13 @@ import { gql } from "@apollo/client";
|
||||
import dayjs from "../../utils/day.js";
|
||||
import componentList from "./componentList.js";
|
||||
|
||||
const createDashboardQuery = (state) => {
|
||||
const createDashboardQuery = (items) => {
|
||||
const componentBasedAdditions =
|
||||
state &&
|
||||
Array.isArray(state.layout) &&
|
||||
state.layout.map((item) => componentList[item.i].gqlFragment || "").join("");
|
||||
Array.isArray(items) &&
|
||||
items
|
||||
.map((item) => (componentList[item.i] && componentList[item.i].gqlFragment) || "")
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
return gql`
|
||||
query QUERY_DASHBOARD_DETAILS { ${componentBasedAdditions || ""}
|
||||
monthly_sales: jobs(where: {_and: [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Icon, { SyncOutlined } from "@ant-design/icons";
|
||||
import { cloneDeep, isEmpty } from "lodash";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { Button, Dropdown, Space } from "antd";
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
@@ -34,14 +34,25 @@ const mapDispatchToProps = () => ({
|
||||
|
||||
export function DashboardGridComponent({ currentUser, bodyshop }) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState({
|
||||
...(bodyshop.associations[0].user.dashboardlayout
|
||||
? bodyshop.associations[0].user.dashboardlayout
|
||||
: { items: [], layout: {}, layouts: [] })
|
||||
const [state, setState] = useState(() => {
|
||||
const persisted = bodyshop.associations[0].user.dashboardlayout;
|
||||
// Normalize persisted structure to avoid malformed shapes that can cause recursive layout recalculations
|
||||
if (persisted) {
|
||||
return {
|
||||
items: Array.isArray(persisted.items) ? persisted.items : [],
|
||||
layout: Array.isArray(persisted.layout) ? persisted.layout : [],
|
||||
layouts: typeof persisted.layouts === "object" && !Array.isArray(persisted.layouts) ? persisted.layouts : {},
|
||||
cols: persisted.cols
|
||||
};
|
||||
}
|
||||
return { items: [], layout: [], layouts: {}, cols: 12 };
|
||||
});
|
||||
const notification = useNotification();
|
||||
|
||||
const { loading, error, data, refetch } = useQuery(createDashboardQuery(state), {
|
||||
// Memoize the query document so Apollo doesn't treat each render as a brand-new query causing continuous re-fetches
|
||||
const dashboardQueryDoc = useMemo(() => createDashboardQuery(state.items), [state.items]);
|
||||
|
||||
const { loading, error, data, refetch } = useQuery(dashboardQueryDoc, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only"
|
||||
});
|
||||
@@ -49,21 +60,32 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
|
||||
const [updateLayout] = useMutation(UPDATE_DASHBOARD_LAYOUT);
|
||||
|
||||
const handleLayoutChange = async (layout, layouts) => {
|
||||
logImEXEvent("dashboard_change_layout");
|
||||
try {
|
||||
logImEXEvent("dashboard_change_layout");
|
||||
|
||||
setState({ ...state, layout, layouts });
|
||||
setState((prev) => ({ ...prev, layout, layouts }));
|
||||
|
||||
const result = await updateLayout({
|
||||
variables: {
|
||||
email: currentUser.email,
|
||||
layout: { ...state, layout, layouts }
|
||||
const result = await updateLayout({
|
||||
variables: {
|
||||
email: currentUser.email,
|
||||
layout: { ...state, layout, layouts }
|
||||
}
|
||||
});
|
||||
|
||||
if (result?.errors && result.errors.length) {
|
||||
const errorMessages = result.errors.map((e) => e?.message || String(e));
|
||||
notification.error({
|
||||
message: t("dashboard.errors.updatinglayout", {
|
||||
message: errorMessages.join("; ")
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!isEmpty(result?.errors)) {
|
||||
} catch (err) {
|
||||
// Catch any unexpected errors (including potential cyclic JSON issues) so the promise never rejects unhandled
|
||||
console.error("Dashboard layout update failed", err);
|
||||
notification.error({
|
||||
message: t("dashboard.errors.updatinglayout", {
|
||||
message: JSON.stringify(result.errors)
|
||||
message: err?.message || String(err)
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -80,19 +102,26 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
|
||||
};
|
||||
|
||||
const handleAddComponent = (e) => {
|
||||
logImEXEvent("dashboard_add_component", { name: e.key });
|
||||
setState({
|
||||
...state,
|
||||
items: [
|
||||
...state.items,
|
||||
// Avoid passing the full AntD menu click event (contains circular refs) to analytics
|
||||
logImEXEvent("dashboard_add_component", { key: e.key });
|
||||
const compSpec = componentList[e.key] || {};
|
||||
const minW = compSpec.minW || 1;
|
||||
const minH = compSpec.minH || 1;
|
||||
const baseW = compSpec.w || 2;
|
||||
const baseH = compSpec.h || 2;
|
||||
setState((prev) => {
|
||||
const nextItems = [
|
||||
...prev.items,
|
||||
{
|
||||
i: e.key,
|
||||
x: (state.items.length * 2) % (state.cols || 12),
|
||||
y: 99, // puts it at the bottom
|
||||
w: componentList[e.key].w || 2,
|
||||
h: componentList[e.key].h || 2
|
||||
// Position near bottom: use a large y so RGL places it last without triggering cascading relayout loops
|
||||
x: (prev.items.length * 2) % (prev.cols || 12),
|
||||
y: 1000,
|
||||
w: Math.max(baseW, minW),
|
||||
h: Math.max(baseH, minH)
|
||||
}
|
||||
]
|
||||
];
|
||||
return { ...prev, items: nextItems };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -130,25 +159,33 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
|
||||
className="layout"
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||
width="100%"
|
||||
layouts={state.layouts}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
>
|
||||
{state.items.map((item) => {
|
||||
const TheComponent = componentList[item.i].component;
|
||||
const spec = componentList[item.i] || {};
|
||||
const TheComponent = spec.component;
|
||||
const minW = spec.minW || 1;
|
||||
const minH = spec.minH || 1;
|
||||
// Ensure current width/height respect minimums to avoid react-grid-layout prop warnings
|
||||
const safeItem = {
|
||||
...item,
|
||||
w: Math.max(item.w || spec.w || minW, minW),
|
||||
h: Math.max(item.h || spec.h || minH, minH)
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={item.i}
|
||||
key={safeItem.i}
|
||||
data-grid={{
|
||||
...item,
|
||||
minH: componentList[item.i].minH || 1,
|
||||
minW: componentList[item.i].minW || 1
|
||||
...safeItem,
|
||||
minH,
|
||||
minW
|
||||
}}
|
||||
>
|
||||
<LoadingSkeleton loading={loading}>
|
||||
<Icon
|
||||
component={MdClose}
|
||||
key={item.i}
|
||||
key={safeItem.i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
zIndex: "2",
|
||||
@@ -156,9 +193,9 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
|
||||
top: ".25rem",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
onClick={() => handleRemoveComponent(item.i)}
|
||||
onClick={() => handleRemoveComponent(safeItem.i)}
|
||||
/>
|
||||
<TheComponent className="dashboard-card" bodyshop={bodyshop} data={dashboardData} />
|
||||
{TheComponent && <TheComponent className="dashboard-card" bodyshop={bodyshop} data={dashboardData} />}
|
||||
</LoadingSkeleton>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,11 +39,15 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount,
|
||||
return showUnreadOnly ? { ...baseWhereClause, read: { _is_null: true } } : baseWhereClause;
|
||||
}, [baseWhereClause, showUnreadOnly]);
|
||||
|
||||
// before you call useQuery, compute skip once so you can reuse it
|
||||
const skipQuery = !userAssociationId || !isEmployee;
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchMore,
|
||||
loading: queryLoading,
|
||||
refetch
|
||||
refetch,
|
||||
error
|
||||
} = useQuery(GET_NOTIFICATIONS, {
|
||||
variables: {
|
||||
limit: INITIAL_NOTIFICATIONS,
|
||||
@@ -52,14 +56,26 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount,
|
||||
},
|
||||
fetchPolicy: "cache-and-network",
|
||||
notifyOnNetworkStatusChange: true,
|
||||
errorPolicy: "all",
|
||||
pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
|
||||
skip: !userAssociationId || !isEmployee,
|
||||
onError: (err) => {
|
||||
console.error(`Error polling Notifications: ${err?.message || ""}`);
|
||||
setTimeout(() => refetch(), day.duration(2, "seconds").asMilliseconds());
|
||||
}
|
||||
skip: skipQuery
|
||||
});
|
||||
|
||||
// Replace onError with a side-effect that reacts to the hook’s `error`
|
||||
useEffect(() => {
|
||||
if (!error || skipQuery) return;
|
||||
|
||||
console.error(`Error polling Notifications: ${error?.message || ""}`);
|
||||
|
||||
const t = setTimeout(() => {
|
||||
// Guard: if component unmounted or query now skipped, do nothing
|
||||
if (!skipQuery) {
|
||||
refetch().catch((e) => console.error("Refetch failed:", e?.message || e));
|
||||
}
|
||||
}, day.duration(2, "seconds").asMilliseconds());
|
||||
|
||||
return () => clearTimeout(t);
|
||||
}, [error, refetch, skipQuery]);
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
// Prevent open + close behavior from the header
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Card, Col, Form, Input } from "antd";
|
||||
import { LockOutlined } from "@ant-design/icons";
|
||||
import { Button, Card, Col, Form, Input, Space, Switch, Tooltip, Typography } from "antd";
|
||||
import { AudioMutedOutlined, LockOutlined, SoundOutlined } from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -10,6 +10,8 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
import NotificationSettingsForm from "../notification-settings/notification-settings-form.component.jsx";
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { QUERY_ACTIVE_ASSOCIATION_SOUND, UPDATE_NEW_MESSAGE_SOUND } from "../../graphql/user.queries.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser
|
||||
@@ -48,6 +50,28 @@ export default connect(
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Notification sound (associations.new_message_sound) ----
|
||||
const email = currentUser?.email;
|
||||
const { data: assocData, loading: assocLoading } = useQuery(QUERY_ACTIVE_ASSOCIATION_SOUND, {
|
||||
variables: { email },
|
||||
skip: !email,
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "cache-first"
|
||||
});
|
||||
const association = assocData?.associations?.[0];
|
||||
// Treat null/undefined as ON for backward-compat
|
||||
const soundEnabled = association?.new_message_sound === true;
|
||||
|
||||
const [updateNewMessageSound, { loading: updatingSound }] = useMutation(UPDATE_NEW_MESSAGE_SOUND, {
|
||||
update(cache, { data }) {
|
||||
const updated = data?.update_associations_by_pk;
|
||||
if (!updated) return;
|
||||
cache.modify({
|
||||
id: cache.identify({ __typename: "associations", id: updated.id }),
|
||||
fields: { new_message_sound: () => updated.new_message_sound }
|
||||
});
|
||||
}
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Col span={24}>
|
||||
@@ -80,6 +104,7 @@ export default connect(
|
||||
</Card>
|
||||
</Form>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form onFinish={handleChangePassword} autoComplete={"no"} initialValues={currentUser} layout="vertical">
|
||||
<Card
|
||||
@@ -119,6 +144,52 @@ export default connect(
|
||||
</Card>
|
||||
</Form>
|
||||
</Col>
|
||||
|
||||
{association && (
|
||||
<Col span={24}>
|
||||
<Card title={t("user.labels.user_settings")}>
|
||||
<Space align="center" size="large">
|
||||
<Typography.Text>{t("user.labels.play_sound_for_new_messages")}</Typography.Text>
|
||||
<Tooltip
|
||||
title={soundEnabled ? t("user.labels.notification_sound_on") : t("user.labels.notification_sound_off")}
|
||||
>
|
||||
<Switch
|
||||
checkedChildren={<SoundOutlined />}
|
||||
unCheckedChildren={<AudioMutedOutlined />}
|
||||
checked={!!soundEnabled}
|
||||
loading={assocLoading || updatingSound}
|
||||
onChange={(checked) => {
|
||||
updateNewMessageSound({
|
||||
variables: { id: association.id, value: checked },
|
||||
optimisticResponse: {
|
||||
update_associations_by_pk: {
|
||||
__typename: "associations",
|
||||
id: association.id,
|
||||
new_message_sound: checked
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
notification.success({
|
||||
message: checked
|
||||
? t("user.labels.notification_sound_enabled")
|
||||
: t("user.labels.notification_sound_disabled")
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
notification.error({ message: e.message || "Failed to update setting" });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 8 }}>
|
||||
{t("user.labels.notification_sound_help")}
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
{scenarioNotificationsOn && (
|
||||
<Col span={24}>
|
||||
<NotificationSettingsForm />
|
||||
|
||||
@@ -20,8 +20,8 @@ export const DELETE_BILL = gql`
|
||||
`;
|
||||
|
||||
export const QUERY_ALL_BILLS_PAGINATED = gql`
|
||||
query QUERY_ALL_BILLS_PAGINATED($offset: Int, $limit: Int, $order: [bills_order_by!]!) {
|
||||
bills(offset: $offset, limit: $limit, order_by: $order) {
|
||||
query QUERY_ALL_BILLS_PAGINATED($offset: Int, $limit: Int, $order: [bills_order_by!]!, $where: bills_bool_exp) {
|
||||
bills(offset: $offset, limit: $limit, order_by: $order, where: $where) {
|
||||
id
|
||||
vendorid
|
||||
vendor {
|
||||
|
||||
@@ -5,6 +5,7 @@ export const QUERY_SHOP_ASSOCIATIONS = gql`
|
||||
associations(where: { shopid: { _eq: $shopid } }) {
|
||||
id
|
||||
authlevel
|
||||
new_message_sound
|
||||
shopid
|
||||
user {
|
||||
email
|
||||
@@ -28,6 +29,26 @@ export const UPDATE_ASSOCIATION = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
// Query to load the active association for a given user and get the new_message_sound flag
|
||||
export const QUERY_ACTIVE_ASSOCIATION_SOUND = gql`
|
||||
query QUERY_ACTIVE_ASSOCIATION_SOUND($email: String!) {
|
||||
associations(where: { _and: { useremail: { _eq: $email }, active: { _eq: true } } }) {
|
||||
id
|
||||
new_message_sound
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Mutation to update just the new_message_sound field
|
||||
export const UPDATE_NEW_MESSAGE_SOUND = gql`
|
||||
mutation UPDATE_NEW_MESSAGE_SOUND($id: uuid!, $value: Boolean) {
|
||||
update_associations_by_pk(pk_columns: { id: $id }, _set: { new_message_sound: $value }) {
|
||||
id
|
||||
new_message_sound
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const INSERT_EULA_ACCEPTANCE = gql`
|
||||
mutation INSERT_EULA_ACCEPTANCE($eulaAcceptance: eula_acceptances_insert_input!) {
|
||||
insert_eula_acceptances_one(object: $eulaAcceptance) {
|
||||
@@ -77,6 +98,7 @@ export const QUERY_KANBAN_SETTINGS = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_KANBAN_SETTINGS = gql`
|
||||
mutation UPDATE_KANBAN_SETTINGS($id: uuid!, $ks: jsonb) {
|
||||
update_associations_by_pk(pk_columns: { id: $id }, _set: { kanban_settings: $ks }) {
|
||||
|
||||
@@ -32,7 +32,7 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte
|
||||
const history = useNavigate();
|
||||
const [state, setState] = useLocalStorage("bills_list_sort", {
|
||||
sortedInfo: {},
|
||||
filteredInfo: { text: "" }
|
||||
filteredInfo: { vendorname: [] }
|
||||
});
|
||||
const Templates = TemplateList("bill");
|
||||
const { t } = useTranslation();
|
||||
@@ -49,8 +49,7 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte
|
||||
vendor: { name: order === "descend" ? "desc" : "asc" }
|
||||
}),
|
||||
filters: (vendorsData?.vendors || []).map((v) => ({ text: v.name, value: v.id })),
|
||||
filteredValue: state.filteredInfo.vendorname || null,
|
||||
onFilter: (value, record) => record.vendorid === value,
|
||||
filteredValue: search.vendorIds ? search.vendorIds.split(",") : null,
|
||||
sortOrder: state.sortedInfo.columnKey === "vendorname" && state.sortedInfo.order,
|
||||
render: (text, record) => <span>{record.vendor.name}</span>
|
||||
},
|
||||
@@ -166,22 +165,37 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte
|
||||
];
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
// Persist filters (including vendorname) and sorting
|
||||
setState({ ...state, filteredInfo: { ...state.filteredInfo, ...filters }, sortedInfo: sorter });
|
||||
setState({
|
||||
sortedInfo: sorter,
|
||||
filteredInfo: { ...state.filteredInfo, vendorname: filters.vendorname || [] }
|
||||
});
|
||||
|
||||
search.page = pagination.current;
|
||||
if (filters.vendorname && filters.vendorname.length) {
|
||||
search.vendorIds = filters.vendorname.join(",");
|
||||
} else {
|
||||
delete search.vendorIds;
|
||||
}
|
||||
if (sorter && sorter.column && sorter.column.sortObject) {
|
||||
search.searchObj = JSON.stringify(sorter.column.sortObject(sorter.order));
|
||||
delete search.sortcolumn;
|
||||
delete search.sortorder;
|
||||
} else {
|
||||
delete search.searchObj;
|
||||
search.sortcolumn = sorter.order ? sorter.columnKey : null;
|
||||
search.sortorder = sorter.order;
|
||||
}
|
||||
search.sort = JSON.stringify({ [sorter.columnKey]: sorter.order });
|
||||
history({ search: queryString.stringify(search) });
|
||||
logImEXEvent("bills_list_sort_filter", { pagination, filters, sorter });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!search.vendorIds && state.filteredInfo.vendorname && state.filteredInfo.vendorname.length) {
|
||||
search.vendorIds = state.filteredInfo.vendorname.join(",");
|
||||
history({ search: queryString.stringify(search) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (search.search && search.search.trim() !== "") {
|
||||
searchBills();
|
||||
|
||||
@@ -49,7 +49,8 @@ export function BillsPageContainer({ setBreadcrumbs, setSelectedHeader }) {
|
||||
: {
|
||||
[sortcolumn || "date"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"
|
||||
}
|
||||
]
|
||||
],
|
||||
where: searchParams.vendorIds ? { vendorid: { _in: searchParams.vendorIds.split(",") } } : undefined
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,12 @@ import LoadingSpinner from "../../components/loading-spinner/loading-spinner.com
|
||||
import PartnerPingComponent from "../../components/partner-ping/partner-ping.component";
|
||||
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
|
||||
import UpdateAlert from "../../components/update-alert/update-alert.component";
|
||||
import { selectBodyshop, selectInstanceConflict, selectPartsManagementOnly } from "../../redux/user/user.selectors";
|
||||
import {
|
||||
selectBodyshop,
|
||||
selectCurrentUser,
|
||||
selectInstanceConflict,
|
||||
selectPartsManagementOnly
|
||||
} from "../../redux/user/user.selectors";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
|
||||
import useAlertsNotifications from "../../hooks/useAlertsNotifications.jsx";
|
||||
import { selectDarkMode } from "../../redux/application/application.selectors.js";
|
||||
@@ -109,10 +114,11 @@ const mapStateToProps = createStructuredSelector({
|
||||
conflict: selectInstanceConflict,
|
||||
bodyshop: selectBodyshop,
|
||||
partsManagementOnly: selectPartsManagementOnly,
|
||||
isDarkMode: selectDarkMode
|
||||
isDarkMode: selectDarkMode,
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode }) {
|
||||
export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode, currentUser }) {
|
||||
const { t } = useTranslation();
|
||||
const [chatVisible] = useState(false);
|
||||
const didMount = useRef(false);
|
||||
@@ -588,7 +594,7 @@ export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode })
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatAffixContainer bodyshop={bodyshop} chatVisible={chatVisible} />
|
||||
<ChatAffixContainer bodyshop={bodyshop} currentUser={currentUser} chatVisible={chatVisible} />
|
||||
<Layout style={{ minHeight: "100vh" }} className="layout-container">
|
||||
<UpdateAlert />
|
||||
<HeaderContainer />
|
||||
|
||||
@@ -144,6 +144,11 @@
|
||||
"tasks_updated": "Task '{{title}}' updated by {{updatedBy}}"
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"manager": {
|
||||
"description": "Click anywhere to enable the message ding."
|
||||
}
|
||||
},
|
||||
"billlines": {
|
||||
"actions": {
|
||||
"newline": "New Line"
|
||||
@@ -3806,7 +3811,14 @@
|
||||
"labels": {
|
||||
"actions": "Actions",
|
||||
"changepassword": "Change Password",
|
||||
"profileinfo": "Profile Info"
|
||||
"profileinfo": "Profile Info",
|
||||
"user_settings": "User Settings",
|
||||
"play_sound_for_new_messages": "Play a sound for new messages",
|
||||
"notification_sound_on": "Sound is ON",
|
||||
"notification_sound_off": "Sound is OFF",
|
||||
"notification_sound_enabled": "Notification sound enabled",
|
||||
"notification_sound_disabled": "Notification sound disabled",
|
||||
"notification_sound_help": "Toggle the ding for incoming chat messages."
|
||||
},
|
||||
"successess": {
|
||||
"passwordchanged": "Password changed successfully. "
|
||||
|
||||
@@ -144,6 +144,11 @@
|
||||
"tasks_updated": ""
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"manager": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"billlines": {
|
||||
"actions": {
|
||||
"newline": ""
|
||||
@@ -3807,7 +3812,14 @@
|
||||
"labels": {
|
||||
"actions": "",
|
||||
"changepassword": "",
|
||||
"profileinfo": ""
|
||||
"profileinfo": "",
|
||||
"user_settings": "",
|
||||
"play_sound_for_new_messages": "",
|
||||
"notification_sound_on": "",
|
||||
"notification_sound_off": "",
|
||||
"notification_sound_enabled": "",
|
||||
"notification_sound_disabled": "",
|
||||
"notification_sound_help": ""
|
||||
},
|
||||
"successess": {
|
||||
"passwordchanged": ""
|
||||
|
||||
@@ -144,6 +144,11 @@
|
||||
"tasks_updated": ""
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"manager": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"billlines": {
|
||||
"actions": {
|
||||
"newline": ""
|
||||
@@ -3807,7 +3812,14 @@
|
||||
"labels": {
|
||||
"actions": "",
|
||||
"changepassword": "",
|
||||
"profileinfo": ""
|
||||
"profileinfo": "",
|
||||
"user_settings": "",
|
||||
"play_sound_for_new_messages": "",
|
||||
"notification_sound_on": "",
|
||||
"notification_sound_off": "",
|
||||
"notification_sound_enabled": "",
|
||||
"notification_sound_disabled": "",
|
||||
"notification_sound_help": ""
|
||||
},
|
||||
"successess": {
|
||||
"passwordchanged": ""
|
||||
|
||||
164
client/src/utils/singleTabAudioLeader.js
Normal file
164
client/src/utils/singleTabAudioLeader.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// src/utils/singleTabAudioLeader.js
|
||||
// Ensures only one tab ("leader") plays sounds per bodyshop.
|
||||
//
|
||||
// Storage key: localStorage["imex:sound:leader:<bodyshopId>"] = { id, ts }
|
||||
// Channel: new BroadcastChannel("imex:sound:<bodyshopId>")
|
||||
|
||||
const STORAGE_PREFIX = "imex:sound:leader:";
|
||||
const CHANNEL_PREFIX = "imex:sound:";
|
||||
|
||||
const TTL_MS = 60_000; // leader expires after 60s without heartbeat
|
||||
const HEARTBEAT_MS = 20_000; // leader refresh interval
|
||||
const WATCHDOG_MS = 10_000; // how often non-leaders check for stale leader
|
||||
|
||||
const TAB_ID =
|
||||
typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
|
||||
|
||||
function channelSupported() {
|
||||
try {
|
||||
return "BroadcastChannel" in window;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getChannel(bodyshopId) {
|
||||
if (!channelSupported() || !bodyshopId) return null;
|
||||
try {
|
||||
return new BroadcastChannel(CHANNEL_PREFIX + String(bodyshopId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function lsKey(bodyshopId) {
|
||||
return STORAGE_PREFIX + String(bodyshopId);
|
||||
}
|
||||
|
||||
function readLeader(bodyshopId) {
|
||||
if (!bodyshopId) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(lsKey(bodyshopId));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeLeader(record, bodyshopId) {
|
||||
if (!bodyshopId) return;
|
||||
try {
|
||||
localStorage.setItem(lsKey(bodyshopId), JSON.stringify(record));
|
||||
const bc = getChannel(bodyshopId);
|
||||
if (bc) {
|
||||
bc.postMessage({ type: "leader-update", payload: { ...record, bodyshopId } });
|
||||
bc.close();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function removeLeader(bodyshopId) {
|
||||
if (!bodyshopId) return;
|
||||
try {
|
||||
const cur = readLeader(bodyshopId);
|
||||
if (cur?.id === TAB_ID) {
|
||||
localStorage.removeItem(lsKey(bodyshopId));
|
||||
const bc = getChannel(bodyshopId);
|
||||
if (bc) {
|
||||
bc.postMessage({ type: "leader-removed", payload: { id: TAB_ID, bodyshopId } });
|
||||
bc.close();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function now() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function isStale(rec) {
|
||||
return !rec || now() - rec.ts > TTL_MS;
|
||||
}
|
||||
|
||||
function claimLeadership(bodyshopId) {
|
||||
const rec = { id: TAB_ID, ts: now() };
|
||||
writeLeader(rec, bodyshopId);
|
||||
return rec;
|
||||
}
|
||||
|
||||
/** Is THIS tab currently the leader (and not stale)? */
|
||||
export function isLeaderTab(bodyshopId) {
|
||||
const rec = readLeader(bodyshopId);
|
||||
return !!rec && rec.id === TAB_ID && !isStale(rec);
|
||||
}
|
||||
|
||||
/** Force this tab to become the leader right now. */
|
||||
export function claimLeadershipNow(bodyshopId) {
|
||||
return claimLeadership(bodyshopId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize leader election/heartbeat for this tab (scoped by bodyshopId).
|
||||
* Call once (e.g., in SoundWrapper). Returns a cleanup function.
|
||||
*/
|
||||
export function initSingleTabAudioLeader(bodyshopId) {
|
||||
if (!bodyshopId)
|
||||
return () => {
|
||||
//
|
||||
};
|
||||
|
||||
// If no leader or stale, try to claim after a tiny delay (reduce startup contention)
|
||||
if (isStale(readLeader(bodyshopId))) {
|
||||
setTimeout(() => claimLeadership(bodyshopId), 100);
|
||||
}
|
||||
|
||||
// If this tab becomes focused/visible, it can claim leadership
|
||||
const onFocus = () => claimLeadership(bodyshopId);
|
||||
const onVis = () => {
|
||||
if (document.visibilityState === "visible") claimLeadership(bodyshopId);
|
||||
};
|
||||
window.addEventListener("focus", onFocus);
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
|
||||
// Heartbeat from the leader to keep record fresh
|
||||
const heartbeat = setInterval(() => {
|
||||
if (!isLeaderTab(bodyshopId)) return;
|
||||
writeLeader({ id: TAB_ID, ts: now() }, bodyshopId);
|
||||
}, HEARTBEAT_MS);
|
||||
|
||||
// Watchdog: if leader is stale, try to claim (even if we're not focused)
|
||||
const watchdog = setInterval(() => {
|
||||
const cur = readLeader(bodyshopId);
|
||||
if (isStale(cur)) claimLeadership(bodyshopId);
|
||||
}, WATCHDOG_MS);
|
||||
|
||||
// If this tab was the leader, clean up on unload
|
||||
const onUnload = () => removeLeader(bodyshopId);
|
||||
window.addEventListener("beforeunload", onUnload);
|
||||
|
||||
// Per-bodyshop BroadcastChannel listener (optional/no-op)
|
||||
const bc = getChannel(bodyshopId);
|
||||
const onBC = bc
|
||||
? () => {
|
||||
// No state kept here; localStorage read is the source of truth.
|
||||
}
|
||||
: null;
|
||||
if (bc && onBC) bc.addEventListener("message", onBC);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", onFocus);
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
window.removeEventListener("beforeunload", onUnload);
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(watchdog);
|
||||
if (bc && onBC) {
|
||||
bc.removeEventListener("message", onBC);
|
||||
bc.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
97
client/src/utils/soundManager.js
Normal file
97
client/src/utils/soundManager.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// src/utils/soundManager.js
|
||||
// Handles audio init, autoplay unlock, and queued plays.
|
||||
// When a tab successfully unlocks audio, it CLAIMS LEADERSHIP immediately for that bodyshop.
|
||||
|
||||
import { claimLeadershipNow } from "./singleTabAudioLeader";
|
||||
|
||||
let baseAudio = null;
|
||||
let unlocked = false;
|
||||
let queuedPlays = 0;
|
||||
let installingUnlockHandlers = false;
|
||||
|
||||
/**
|
||||
* Initialize the new-message sound.
|
||||
* @param {string} url
|
||||
* @param {number} volume
|
||||
*/
|
||||
export function initNewMessageSound(url, volume = 0.7) {
|
||||
baseAudio = new Audio(url);
|
||||
baseAudio.preload = "auto";
|
||||
baseAudio.volume = volume;
|
||||
}
|
||||
|
||||
/** Has this tab unlocked audio? (optional helper) */
|
||||
export function isAudioUnlocked() {
|
||||
return unlocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocks audio if not already unlocked.
|
||||
* On success, this tab immediately becomes the sound LEADER for the given bodyshop.
|
||||
*/
|
||||
export async function unlockAudio(bodyshopId) {
|
||||
if (unlocked) return;
|
||||
try {
|
||||
// Chrome/Safari: playing any media (even muted) after a gesture unlocks audio.
|
||||
const a = new Audio();
|
||||
a.muted = true;
|
||||
await a.play().catch(() => {
|
||||
// ignore
|
||||
});
|
||||
unlocked = true;
|
||||
|
||||
// Immediately become the leader because THIS tab can actually play sound.
|
||||
claimLeadershipNow(bodyshopId);
|
||||
|
||||
// Flush exactly one queued ding (avoid spamming if many queued while locked)
|
||||
if (queuedPlays > 0 && baseAudio) {
|
||||
queuedPlays = 0;
|
||||
const b = baseAudio.cloneNode(true);
|
||||
b.play().catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
removeUnlockListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/** Installs listeners to unlock audio on first gesture. */
|
||||
function addUnlockListeners(bodyshopId) {
|
||||
if (installingUnlockHandlers) return;
|
||||
installingUnlockHandlers = true;
|
||||
const handler = () => unlockAudio(bodyshopId);
|
||||
window.addEventListener("click", handler, { once: true, passive: true });
|
||||
window.addEventListener("touchstart", handler, { once: true, passive: true });
|
||||
window.addEventListener("keydown", handler, { once: true });
|
||||
}
|
||||
|
||||
/** Removes listeners to unlock audio on first gesture. */
|
||||
function removeUnlockListeners() {
|
||||
// With {once:true} they self-remove; we only reset the flag.
|
||||
installingUnlockHandlers = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the new-message ding. If blocked, queue one and wait for first gesture.
|
||||
*/
|
||||
export async function playNewMessageSound(bodyshopId) {
|
||||
if (!baseAudio) return;
|
||||
try {
|
||||
const a = baseAudio.cloneNode(true);
|
||||
await a.play();
|
||||
} catch (err) {
|
||||
// Most common: NotAllowedError due to missing prior gesture
|
||||
if (err?.name === "NotAllowedError") {
|
||||
queuedPlays = Math.min(queuedPlays + 1, 1); // cap at 1
|
||||
addUnlockListeners(bodyshopId);
|
||||
|
||||
// Let the app know we need user interaction (optional UI prompt)
|
||||
window.dispatchEvent(new CustomEvent("sound-needs-unlock"));
|
||||
return;
|
||||
}
|
||||
// Other errors can be logged
|
||||
|
||||
console.error("Audio play error:", err);
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@ services:
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-job-totals --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket parts-estimates --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
"
|
||||
# Node App: The Main IMEX API
|
||||
node-app:
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
headers:
|
||||
- name: x-imex-auth
|
||||
value_from_env: DATAPUMP_AUTH
|
||||
- name: CARFAX Data Pump
|
||||
webhook: '{{HASURA_API_URL}}/data/carfax'
|
||||
schedule: 0 7 * * 6
|
||||
include_in_metadata: true
|
||||
payload: {}
|
||||
headers:
|
||||
- name: x-imex-auth
|
||||
value_from_env: DATAPUMP_AUTH
|
||||
comment: Project Mexico
|
||||
- name: Chatter Data Pump
|
||||
webhook: '{{HASURA_API_URL}}/data/chatter'
|
||||
schedule: 45 5 * * *
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
- default_prod_list_view
|
||||
- id
|
||||
- kanban_settings
|
||||
- new_message_sound
|
||||
- notification_settings
|
||||
- notifications_autoadd
|
||||
- qbo_realmId
|
||||
@@ -232,6 +233,7 @@
|
||||
- authlevel
|
||||
- default_prod_list_view
|
||||
- kanban_settings
|
||||
- new_message_sound
|
||||
- notification_settings
|
||||
- notifications_autoadd
|
||||
- qbo_realmId
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."associations" add column "new_message_sound" boolean
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."associations" add column "new_message_sound" boolean
|
||||
null;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "public"."associations" ALTER COLUMN "new_message_sound" drop default;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."associations" alter column "new_message_sound" set default 'true';
|
||||
@@ -6,7 +6,7 @@ const InstanceManager = require("../utils/instanceMgr").default;
|
||||
const { isString, isEmpty } = require("lodash");
|
||||
const fs = require("fs");
|
||||
const client = require("../graphql-client/graphql-client").client;
|
||||
const { sendServerEmail } = require("../email/sendemail");
|
||||
const { sendServerEmail, sendMexicoBillingEmail } = require("../email/sendemail");
|
||||
const { uploadFileToS3 } = require("../utils/s3");
|
||||
const crypto = require("crypto");
|
||||
|
||||
@@ -168,6 +168,32 @@ async function processShopData(shopsToProcess, start, end, skipUpload, ignoreDat
|
||||
await uploadViaSFTP(jsonObj);
|
||||
}
|
||||
|
||||
await sendMexicoBillingEmail({
|
||||
subject: `${shopid.toUpperCase()}_Mexico${InstanceManager({
|
||||
imex: "IO",
|
||||
rome: "RO"
|
||||
})}_${moment().format("MMDDYYYY")} ROs ${jsonObj.count} Error ${errorCode(jsonObj)}`,
|
||||
text: `Errors:\n${JSON.stringify(
|
||||
erroredJobs.map((ej) => ({
|
||||
ro_number: ej.job?.ro_number,
|
||||
jobid: ej.job?.id,
|
||||
error: ej.error
|
||||
})),
|
||||
null,
|
||||
2
|
||||
)}\n\nUploaded:\n${JSON.stringify(
|
||||
{
|
||||
bodyshopid: bodyshop.id,
|
||||
imexshopid: shopid,
|
||||
count: jsonObj.count,
|
||||
filename: jsonObj.filename,
|
||||
result: jsonObj.result
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
});
|
||||
|
||||
allXMLResults.push({
|
||||
bodyshopid: bodyshop.id,
|
||||
imexshopid: shopid,
|
||||
@@ -402,3 +428,14 @@ const generatePartType = (type) => {
|
||||
|
||||
return partTypeMap[type?.toLowerCase()] || null;
|
||||
};
|
||||
|
||||
const errorCode = ({ count, filename, results }) => {
|
||||
if (count === 0) return 1;
|
||||
if (!filename) return 3;
|
||||
const sftpErrorCode = results?.sftpError?.code;
|
||||
if (sftpErrorCode && ["ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT", "ECONNRESET"].includes(sftpErrorCode)) {
|
||||
return 4;
|
||||
}
|
||||
if (sftpErrorCode) return 7;
|
||||
return 0;
|
||||
};
|
||||
|
||||
@@ -79,6 +79,41 @@ const sendServerEmail = async ({ subject, text }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const sendMexicoBillingEmail = async ({ subject, text }) => {
|
||||
if (process.env.NODE_ENV === undefined) return;
|
||||
try {
|
||||
mailer.sendMail(
|
||||
{
|
||||
from: InstanceManager({
|
||||
imex: `ImEX Online API - ${process.env.NODE_ENV} <noreply@imex.online>`,
|
||||
rome: `Rome Online API - ${process.env.NODE_ENV} <noreply@romeonline.io>`
|
||||
}),
|
||||
to: ["mexico@rometech.zohodesk.com"],
|
||||
subject: subject,
|
||||
text: text,
|
||||
ses: {
|
||||
// optional extra arguments for SendRawEmail
|
||||
Tags: [
|
||||
{
|
||||
Name: "tag_name",
|
||||
Value: "tag_value"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
(err, info) => {
|
||||
logger.log("server-email-failure", err ? "error" : "debug", null, null, {
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.log("server-email-failure", "error", null, null, { message: error?.message, stack: error?.stack });
|
||||
}
|
||||
};
|
||||
|
||||
const sendWelcomeEmail = async ({ to, resetLink, dateLine, features, bcc }) => {
|
||||
try {
|
||||
await mailer.sendMail({
|
||||
@@ -420,6 +455,7 @@ ${body.bounce?.bouncedRecipients.map(
|
||||
module.exports = {
|
||||
sendEmail,
|
||||
sendServerEmail,
|
||||
sendMexicoBillingEmail,
|
||||
sendTaskEmail,
|
||||
emailBounce,
|
||||
sendWelcomeEmail
|
||||
|
||||
Reference in New Issue
Block a user