Merge branch 'release/2025-03-14' into feature/IO-3172-RO-Basic-Payments
Signed-off-by: Allan Carr <allan.carr@thinkimex.com> # Conflicts: # client/src/components/header/header.component.jsx # client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx
This commit is contained in:
590
client/package-lock.json
generated
590
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,27 +8,27 @@
|
||||
"private": true,
|
||||
"proxy": "http://localhost:4000",
|
||||
"dependencies": {
|
||||
"@ant-design/pro-layout": "^7.22.0",
|
||||
"@apollo/client": "^3.12.6",
|
||||
"@ant-design/pro-layout": "^7.22.3",
|
||||
"@apollo/client": "^3.13.1",
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@fingerprintjs/fingerprintjs": "^4.5.1",
|
||||
"@fingerprintjs/fingerprintjs": "^4.6.1",
|
||||
"@jsreport/browser-client": "^3.1.0",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"@reduxjs/toolkit": "^2.6.0",
|
||||
"@sentry/cli": "^2.42.2",
|
||||
"@sentry/react": "^9.3.0",
|
||||
"@sentry/vite-plugin": "^3.2.1",
|
||||
"@sentry/vite-plugin": "^3.2.2",
|
||||
"@splitsoftware/splitio-react": "^1.13.0",
|
||||
"@tanem/react-nprogress": "^5.0.53",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"antd": "^5.23.1",
|
||||
"antd": "^5.24.2",
|
||||
"apollo-link-logger": "^2.0.1",
|
||||
"apollo-link-sentry": "^4.1.0",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.7.9",
|
||||
"axios": "^1.8.1",
|
||||
"classnames": "^2.5.1",
|
||||
"css-box-model": "^1.2.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs-business-days2": "^1.2.3",
|
||||
"dayjs-business-days2": "^1.3.0",
|
||||
"dinero.js": "^1.9.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"env-cmd": "^10.1.0",
|
||||
@@ -36,9 +36,9 @@
|
||||
"firebase": "^10.13.2",
|
||||
"graphql": "^16.10.0",
|
||||
"i18next": "^23.15.1",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"libphonenumber-js": "^1.11.18",
|
||||
"libphonenumber-js": "^1.12.4",
|
||||
"logrocket": "^8.1.2",
|
||||
"markerjs2": "^2.32.3",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -48,7 +48,7 @@
|
||||
"query-string": "^9.1.1",
|
||||
"raf-schd": "^4.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-big-calendar": "^1.17.1",
|
||||
"react-big-calendar": "^1.18.0",
|
||||
"react-color": "^2.19.3",
|
||||
"react-cookie": "^7.2.2",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -56,7 +56,7 @@
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-grid-layout": "1.3.4",
|
||||
"react-i18next": "^14.1.3",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-image-lightbox": "^5.1.4",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-number-format": "^5.4.3",
|
||||
@@ -64,9 +64,9 @@
|
||||
"react-product-fruits": "^2.2.61",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-resizable": "^3.0.5",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-router-dom": "^6.30.0",
|
||||
"react-sticky": "^6.0.3",
|
||||
"react-virtuoso": "^4.10.4",
|
||||
"react-virtuoso": "^4.12.5",
|
||||
"recharts": "^2.15.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-actions": "^3.0.3",
|
||||
@@ -74,12 +74,12 @@
|
||||
"redux-saga": "^1.3.0",
|
||||
"redux-state-sync": "^3.1.4",
|
||||
"reselect": "^5.1.1",
|
||||
"sass": "^1.83.4",
|
||||
"sass": "^1.85.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"styled-components": "^6.1.14",
|
||||
"styled-components": "^6.1.15",
|
||||
"subscriptions-transport-ws": "^0.11.0",
|
||||
"use-memo-one": "^1.1.3",
|
||||
"userpilot": "^1.3.6",
|
||||
"userpilot": "^1.3.8",
|
||||
"vite-plugin-ejs": "^1.7.0",
|
||||
"web-vitals": "^3.5.2"
|
||||
},
|
||||
@@ -120,14 +120,14 @@
|
||||
"@rollup/rollup-linux-x64-gnu": "4.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/icons": "^5.5.2",
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@dotenvx/dotenvx": "^1.33.0",
|
||||
"@dotenvx/dotenvx": "^1.38.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@sentry/webpack-plugin": "^3.2.1",
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@sentry/webpack-plugin": "^3.2.2",
|
||||
"@testing-library/cypress": "^10.0.2",
|
||||
"browserslist": "^4.24.4",
|
||||
"browserslist-to-esbuild": "^2.1.1",
|
||||
@@ -138,13 +138,13 @@
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"eslint-plugin-cypress": "^2.15.1",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"globals": "^15.14.0",
|
||||
"globals": "^15.15.0",
|
||||
"memfs": "^4.17.0",
|
||||
"os-browserify": "^0.3.0",
|
||||
"react-error-overlay": "6.0.11",
|
||||
"react-error-overlay": "^6.1.0",
|
||||
"redux-logger": "^3.0.6",
|
||||
"source-map-explorer": "^2.5.3",
|
||||
"vite": "^6.0.7",
|
||||
"vite": "^6.2.0",
|
||||
"vite-plugin-babel": "^1.3.0",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { Button, Result } from "antd";
|
||||
import LogRocket from "logrocket";
|
||||
import React, { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { Route, Routes, useNavigate } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import DocumentEditorContainer from "../components/document-editor/document-editor.container";
|
||||
import ErrorBoundary from "../components/error-boundary/error-boundary.component"; // Component Imports
|
||||
@@ -21,7 +21,7 @@ import "./App.styles.scss";
|
||||
import Eula from "../components/eula/eula.component";
|
||||
import InstanceRenderMgr from "../utils/instanceRenderMgr";
|
||||
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
|
||||
import { SocketProvider } from "../contexts/SocketIO/socketContext.jsx";
|
||||
import { SocketProvider } from "../contexts/SocketIO/useSocket.jsx";
|
||||
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
|
||||
@@ -46,6 +46,9 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
||||
const client = useSplitClient().client;
|
||||
const [listenersAdded, setListenersAdded] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const scenarioNotificationsOn = client?.getTreatment("Realtime_Notifications_UI") === "on";
|
||||
|
||||
useEffect(() => {
|
||||
if (!navigator.onLine) {
|
||||
@@ -200,7 +203,12 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
||||
path="/manage/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SocketProvider bodyshop={bodyshop}>
|
||||
<SocketProvider
|
||||
bodyshop={bodyshop}
|
||||
navigate={navigate}
|
||||
currentUser={currentUser}
|
||||
scenarioNotificationsOn={scenarioNotificationsOn}
|
||||
>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
@@ -212,7 +220,12 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
||||
path="/tech/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SocketProvider bodyshop={bodyshop}>
|
||||
<SocketProvider
|
||||
bodyshop={bodyshop}
|
||||
navigate={navigate}
|
||||
currentUser={currentUser}
|
||||
scenarioNotificationsOn={scenarioNotificationsOn}
|
||||
>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -180,3 +180,13 @@
|
||||
.muted-button:hover {
|
||||
color: darkgrey;
|
||||
}
|
||||
|
||||
.notification-alert-unordered-list {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
.notification-alert-unordered-list-item {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useApolloClient } from "@apollo/client";
|
||||
import { getToken } from "@firebase/messaging";
|
||||
import axios from "axios";
|
||||
import React, { useContext, useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { messaging, requestForToken } from "../../firebase/firebase.utils";
|
||||
import ChatPopupComponent from "../chat-popup/chat-popup.component";
|
||||
import "./chat-affix.styles.scss";
|
||||
@@ -12,7 +12,7 @@ import { registerMessagingHandlers, unregisterMessagingHandlers } from "./regist
|
||||
export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
||||
const { t } = useTranslation();
|
||||
const client = useApolloClient();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
|
||||
useEffect(() => {
|
||||
if (!bodyshop || !bodyshop.messagingservicesid) return;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { Button } from "antd";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
import { connect } from "react-redux";
|
||||
@@ -18,7 +18,7 @@ export function ChatArchiveButton({ conversation, bodyshop }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const [updateConversation] = useMutation(TOGGLE_CONVERSATION_ARCHIVE);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
|
||||
const handleToggleArchive = async () => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { Tag } from "antd";
|
||||
import React, { useContext } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
|
||||
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
import { connect } from "react-redux";
|
||||
@@ -18,7 +17,7 @@ const mapDispatchToProps = () => ({});
|
||||
|
||||
export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
|
||||
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
|
||||
const handleRemoveTag = async (jobId) => {
|
||||
const convId = jobConversations[0].conversationid;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
||||
import axios from "axios";
|
||||
import React, { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext";
|
||||
import { GET_CONVERSATION_DETAILS, CONVERSATION_SUBSCRIPTION_BY_PK } from "../../graphql/conversations.queries";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
|
||||
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import ChatConversationComponent from "./chat-conversation.component";
|
||||
@@ -16,7 +16,7 @@ const mapStateToProps = createStructuredSelector({
|
||||
|
||||
function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
const client = useApolloClient();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
|
||||
|
||||
// Fetch conversation details
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { Input, Spin, Tag, Tooltip } from "antd";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
import { connect } from "react-redux";
|
||||
@@ -20,7 +20,7 @@ export function ChatLabel({ conversation, bodyshop }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState(conversation.label);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
const notification = useNotification();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { PlusCircleFilled } from "@ant-design/icons";
|
||||
import { Button, Form, Popover } from "antd";
|
||||
import React, { useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
|
||||
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
@@ -18,7 +17,7 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
export function ChatNewConversation({ openChatByPhone }) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
|
||||
const handleFinish = (values) => {
|
||||
openChatByPhone({ phone_num: values.phoneNumber, socket });
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import parsePhoneNumber from "libphonenumber-js";
|
||||
import React, { useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
|
||||
@@ -8,7 +7,7 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
@@ -22,7 +21,7 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
|
||||
export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobid, openChatByPhone }) {
|
||||
const { t } = useTranslation();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
const notification = useNotification();
|
||||
|
||||
if (!phone) return <></>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
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 { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -12,8 +12,9 @@ import ChatConversationListComponent from "../chat-conversation-list/chat-conver
|
||||
import ChatConversationContainer from "../chat-conversation/chat-conversation.container";
|
||||
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
|
||||
import "./chat-popup.styles.scss";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
selectedConversation: selectSelectedConversation,
|
||||
@@ -27,7 +28,7 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
|
||||
const { t } = useTranslation();
|
||||
const [pollInterval, setPollInterval] = useState(0);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
const client = useApolloClient(); // Apollo Client instance for cache operations
|
||||
|
||||
// Lazy query for conversations
|
||||
|
||||
@@ -2,13 +2,13 @@ import { PlusOutlined } from "@ant-design/icons";
|
||||
import { useLazyQuery, useMutation } from "@apollo/client";
|
||||
import { Tag } from "antd";
|
||||
import _ from "lodash";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
|
||||
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
|
||||
import ChatTagRo from "./chat-tag-ro.component";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
import { connect } from "react-redux";
|
||||
@@ -22,7 +22,7 @@ const mapDispatchToProps = () => ({});
|
||||
export function ChatTagRoContainer({ conversation, bodyshop }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
|
||||
const [loadRo, { loading, data }] = useLazyQuery(SEARCH_FOR_JOBS);
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ class ErrorBoundary extends React.Component {
|
||||
<Row>
|
||||
<Col offset={6} span={12}>
|
||||
<Collapse bordered={false}>
|
||||
<Collapse.Panel header={t("general.labels.errors")}>
|
||||
<Collapse.Panel key="errors-panel" header={t("general.labels.errors")}>
|
||||
<div>
|
||||
<strong>{this.state.error.message}</strong>
|
||||
</div>
|
||||
|
||||
@@ -78,9 +78,7 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
|
||||
} catch (err) {
|
||||
notification.error({
|
||||
message: t("eula.errors.acceptance.message"),
|
||||
description: t("eula.errors.acceptance.description"),
|
||||
placement: "bottomRight",
|
||||
duration: 5000
|
||||
description: t("eula.errors.acceptance.description")
|
||||
});
|
||||
console.log(`${t("eula.errors.acceptance.message")}`);
|
||||
console.dir({
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import Icon, {
|
||||
import { Badge, Layout, Menu, Spin } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
|
||||
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
import {
|
||||
BankFilled,
|
||||
BarChartOutlined,
|
||||
BellFilled,
|
||||
CarFilled,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleFilled,
|
||||
@@ -25,26 +38,21 @@ import Icon, {
|
||||
UnorderedListOutlined,
|
||||
UserOutlined
|
||||
} from "@ant-design/icons";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { Layout, Menu, Space } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BsKanban } from "react-icons/bs";
|
||||
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
|
||||
import { FiLogOut } from "react-icons/fi";
|
||||
import { GiPayMoney, GiPlayerTime, GiSettingsKnobs } from "react-icons/gi";
|
||||
import { IoBusinessOutline } from "react-icons/io5";
|
||||
import { RiSurveyLine } from "react-icons/ri";
|
||||
import { connect } from "react-redux";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
|
||||
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
import { signOutStart } from "../../redux/user/user.actions";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
|
||||
import day from "../../utils/day.js";
|
||||
|
||||
// Redux mappings
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser,
|
||||
recentItems: selectRecentItems,
|
||||
@@ -53,43 +61,13 @@ const mapStateToProps = createStructuredSelector({
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setBillEnterContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "billEnter"
|
||||
})
|
||||
),
|
||||
setTimeTicketContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "timeTicket"
|
||||
})
|
||||
),
|
||||
setPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "payment" })),
|
||||
setReportCenterContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "reportCenter"
|
||||
})
|
||||
),
|
||||
setBillEnterContext: (context) => dispatch(setModalContext({ context, modal: "billEnter" })),
|
||||
setTimeTicketContext: (context) => dispatch(setModalContext({ context, modal: "timeTicket" })),
|
||||
setPaymentContext: (context) => dispatch(setModalContext({ context, modal: "payment" })),
|
||||
setReportCenterContext: (context) => dispatch(setModalContext({ context, modal: "reportCenter" })),
|
||||
signOutStart: () => dispatch(signOutStart()),
|
||||
setCardPaymentContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "cardPayment"
|
||||
})
|
||||
),
|
||||
setTaskUpsertContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "taskUpsert"
|
||||
})
|
||||
)
|
||||
setCardPaymentContext: (context) => dispatch(setModalContext({ context, modal: "cardPayment" })),
|
||||
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
|
||||
});
|
||||
|
||||
function Header({
|
||||
@@ -115,24 +93,81 @@ function Header({
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { isConnected, scenarioNotificationsOn } = useSocket();
|
||||
const [notificationVisible, setNotificationVisible] = useState(false);
|
||||
const baseTitleRef = useRef(document.title || "");
|
||||
const lastSetTitleRef = useRef("");
|
||||
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||
|
||||
// const deleteBetaCookie = () => {
|
||||
// const cookieExists = document.cookie.split("; ").some((row) => row.startsWith(`betaSwitchImex=`));
|
||||
// if (cookieExists) {
|
||||
// const domain = window.location.hostname.split(".").slice(-2).join(".");
|
||||
// document.cookie = `betaSwitchImex=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.${domain}`;
|
||||
// }
|
||||
// };
|
||||
//
|
||||
// deleteBetaCookie();
|
||||
const {
|
||||
data: unreadData,
|
||||
refetch: refetchUnread,
|
||||
loading: unreadLoading
|
||||
} = useQuery(GET_UNREAD_COUNT, {
|
||||
variables: { associationid: userAssociationId },
|
||||
fetchPolicy: "network-only",
|
||||
pollInterval: isConnected ? 0 : day.duration(60, "seconds").asMilliseconds(),
|
||||
skip: !userAssociationId || !scenarioNotificationsOn
|
||||
});
|
||||
|
||||
const accountingChildren = [];
|
||||
const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count ?? 0;
|
||||
|
||||
accountingChildren.push(
|
||||
useEffect(() => {
|
||||
if (userAssociationId) {
|
||||
refetchUnread().catch((e) => console.error(`Error fetching unread notifications: ${e?.message}`));
|
||||
}
|
||||
}, [refetchUnread, userAssociationId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected && !unreadLoading && userAssociationId) {
|
||||
refetchUnread().catch((e) => console.error(`Error fetching unread notifications: ${e?.message}`));
|
||||
}
|
||||
}, [isConnected, unreadLoading, refetchUnread, userAssociationId]);
|
||||
|
||||
// Keep The unread count in the title.
|
||||
useEffect(() => {
|
||||
const updateTitle = () => {
|
||||
const currentTitle = document.title;
|
||||
// Check if the current title differs from what we last set
|
||||
if (currentTitle !== lastSetTitleRef.current) {
|
||||
// Extract base title by removing any unread count prefix
|
||||
const baseTitleMatch = currentTitle.match(/^\(\d+\)\s*(.*)$/);
|
||||
baseTitleRef.current = baseTitleMatch ? baseTitleMatch[1] : currentTitle;
|
||||
}
|
||||
|
||||
// Apply unread count to the base title
|
||||
const newTitle = unreadCount > 0 ? `(${unreadCount}) ${baseTitleRef.current}` : baseTitleRef.current;
|
||||
|
||||
// Only update if the title has changed to avoid unnecessary DOM writes
|
||||
if (document.title !== newTitle) {
|
||||
document.title = newTitle;
|
||||
lastSetTitleRef.current = newTitle; // Store what we set
|
||||
}
|
||||
};
|
||||
|
||||
// Initial update
|
||||
updateTitle();
|
||||
|
||||
// Poll every 100ms to catch child component changes
|
||||
const interval = setInterval(updateTitle, 100);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
document.title = baseTitleRef.current; // Reset to base title on unmount
|
||||
};
|
||||
}, [unreadCount]); // Re-run when unreadCount changes
|
||||
|
||||
const handleNotificationClick = (e) => {
|
||||
setNotificationVisible(!notificationVisible);
|
||||
if (handleMenuClick) handleMenuClick(e);
|
||||
};
|
||||
|
||||
const accountingChildren = [
|
||||
{
|
||||
key: "bills",
|
||||
id: "header-accounting-bills",
|
||||
icon: <Icon component={FaFileInvoiceDollar} />,
|
||||
icon: <FaFileInvoiceDollar />,
|
||||
label: (
|
||||
<Link to="/manage/bills">
|
||||
<LockWrapper featureName="bills" bodyshop={bodyshop}>
|
||||
@@ -144,42 +179,31 @@ function Header({
|
||||
{
|
||||
key: "enterbills",
|
||||
id: "header-accounting-enterbills",
|
||||
icon: <Icon component={GiPayMoney} />,
|
||||
icon: <GiPayMoney />,
|
||||
label: (
|
||||
<Space>
|
||||
<LockWrapper featureName="bills" bodyshop={bodyshop}>
|
||||
{t("menus.header.enterbills")}
|
||||
</LockWrapper>
|
||||
</Space>
|
||||
<LockWrapper featureName="bills" bodyshop={bodyshop}>
|
||||
{t("menus.header.enterbills")}
|
||||
</LockWrapper>
|
||||
),
|
||||
onClick: () => {
|
||||
onClick: () =>
|
||||
HasFeatureAccess({ featureName: "bills", bodyshop }) &&
|
||||
setBillEnterContext({
|
||||
actions: {},
|
||||
context: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (Simple_Inventory.treatment === "on") {
|
||||
accountingChildren.push(
|
||||
{
|
||||
type: "divider"
|
||||
},
|
||||
{
|
||||
key: "inventory",
|
||||
id: "header-accounting-inventory",
|
||||
icon: <Icon component={FaFileInvoiceDollar} />,
|
||||
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
accountingChildren.push(
|
||||
{
|
||||
type: "divider"
|
||||
setBillEnterContext({
|
||||
actions: {},
|
||||
context: {}
|
||||
})
|
||||
},
|
||||
...(Simple_Inventory.treatment === "on"
|
||||
? [
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "inventory",
|
||||
id: "header-accounting-inventory",
|
||||
icon: <FaFileInvoiceDollar />,
|
||||
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "allpayments",
|
||||
id: "header-accounting-allpayments",
|
||||
@@ -209,16 +233,21 @@ function Header({
|
||||
onClick: () => {
|
||||
setCardPaymentContext({
|
||||
actions: {},
|
||||
context: {}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
accountingChildren.push(
|
||||
{
|
||||
type: "divider"
|
||||
context: null
|
||||
})
|
||||
},
|
||||
...(ImEXPay.treatment === "on"
|
||||
? [
|
||||
{
|
||||
key: "entercardpayments",
|
||||
id: "header-accounting-entercardpayments",
|
||||
icon: <FaCreditCard />,
|
||||
label: t("menus.header.entercardpayment"),
|
||||
onClick: () => setCardPaymentContext({ actions: {}, context: {} })
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "timetickets",
|
||||
id: "header-accounting-timetickets",
|
||||
@@ -230,132 +259,124 @@ function Header({
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
if (bodyshop?.md_tasks_presets?.use_approvals) {
|
||||
accountingChildren.push({
|
||||
key: "ttapprovals",
|
||||
id: "header-accounting-ttapprovals",
|
||||
icon: <FieldTimeOutlined />,
|
||||
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
|
||||
});
|
||||
}
|
||||
accountingChildren.push(
|
||||
},
|
||||
...(bodyshop?.md_tasks_presets?.use_approvals
|
||||
? [
|
||||
{
|
||||
key: "ttapprovals",
|
||||
id: "header-accounting-ttapprovals",
|
||||
icon: <FieldTimeOutlined />,
|
||||
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "entertimetickets",
|
||||
icon: <Icon component={GiPlayerTime} />,
|
||||
id: "header-accounting-entertimetickets",
|
||||
icon: <GiPlayerTime />,
|
||||
label: (
|
||||
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
|
||||
{t("menus.header.entertimeticket")}
|
||||
</LockWrapper>
|
||||
),
|
||||
id: "header-accounting-entertimetickets",
|
||||
onClick: () => {
|
||||
onClick: () =>
|
||||
HasFeatureAccess({ featureName: "timetickets", bodyshop }) &&
|
||||
setTimeTicketContext({
|
||||
actions: {},
|
||||
context: {
|
||||
created_by: currentUser.displayName
|
||||
? currentUser.email.concat(" | ", currentUser.displayName)
|
||||
: currentUser.email
|
||||
}
|
||||
});
|
||||
}
|
||||
setTimeTicketContext({
|
||||
actions: {},
|
||||
context: {
|
||||
created_by: currentUser.displayName
|
||||
? `${currentUser.email} | ${currentUser.displayName}`
|
||||
: currentUser.email
|
||||
}
|
||||
})
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
type: "divider"
|
||||
}
|
||||
);
|
||||
|
||||
const accountingExportChildren = [
|
||||
{
|
||||
key: "receivables",
|
||||
id: "header-accounting-receivables",
|
||||
key: "accountingexport",
|
||||
id: "header-accounting-export",
|
||||
icon: <ExportOutlined />,
|
||||
label: (
|
||||
<Link to="/manage/accounting/receivables">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.accounting-receivables")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.export")}
|
||||
</LockWrapper>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
key: "receivables",
|
||||
id: "header-accounting-receivables",
|
||||
label: (
|
||||
<Link to="/manage/accounting/receivables">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.accounting-receivables")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) ||
|
||||
DmsAp.treatment === "on"
|
||||
? [
|
||||
{
|
||||
key: "payables",
|
||||
id: "header-accounting-payables",
|
||||
label: (
|
||||
<Link to="/manage/accounting/payables">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.accounting-payables")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))
|
||||
? [
|
||||
{
|
||||
key: "payments",
|
||||
id: "header-accounting-payments",
|
||||
label: (
|
||||
<Link to="/manage/accounting/payments">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.accounting-payments")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "exportlogs",
|
||||
id: "header-accounting-exportlogs",
|
||||
label: (
|
||||
<Link to="/manage/accounting/exportlogs">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.export-logs")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
if (!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) || DmsAp.treatment === "on") {
|
||||
accountingExportChildren.push({
|
||||
key: "payables",
|
||||
id: "header-accounting-payables",
|
||||
label: (
|
||||
<Link to="/manage/accounting/payables">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.accounting-payables")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
if (!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))) {
|
||||
accountingExportChildren.push({
|
||||
key: "payments",
|
||||
id: "header-accounting-payments",
|
||||
label: (
|
||||
<Link to="/manage/accounting/payments">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.accounting-payments")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
accountingExportChildren.push(
|
||||
{
|
||||
type: "divider"
|
||||
},
|
||||
{
|
||||
key: "exportlogs",
|
||||
id: "header-accounting-exportlogs",
|
||||
label: (
|
||||
<Link to="/manage/accounting/exportlogs">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.export-logs")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
accountingChildren.push({
|
||||
key: "accountingexport",
|
||||
id: "header-accounting-export",
|
||||
icon: <ExportOutlined />,
|
||||
label: (
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.export")}
|
||||
</LockWrapper>
|
||||
),
|
||||
children: accountingExportChildren
|
||||
});
|
||||
|
||||
const menuItems = [
|
||||
// Left menu items (includes original navigation items)
|
||||
const leftMenuItems = [
|
||||
{
|
||||
key: "home",
|
||||
icon: <HomeFilled />,
|
||||
id: "header-home",
|
||||
icon: <HomeFilled />,
|
||||
label: <Link to="/manage/">{t("menus.header.home")}</Link>
|
||||
},
|
||||
{
|
||||
key: "schedule",
|
||||
id: "header-schedule",
|
||||
icon: <Icon component={FaCalendarAlt} />,
|
||||
icon: <FaCalendarAlt />,
|
||||
label: <Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
|
||||
},
|
||||
{
|
||||
key: "jobssubmenu",
|
||||
id: "header-jobs",
|
||||
icon: <Icon component={FaCarCrash} />,
|
||||
icon: <FaCarCrash />,
|
||||
label: t("menus.header.jobs"),
|
||||
children: [
|
||||
{
|
||||
@@ -388,31 +409,24 @@ function Header({
|
||||
icon: <FileAddOutlined />,
|
||||
label: <Link to="/manage/jobs/new">{t("menus.header.newjob")}</Link>
|
||||
},
|
||||
{
|
||||
type: "divider",
|
||||
id: "header-jobs-divider"
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "alljobs",
|
||||
id: "header-all-jobs",
|
||||
icon: <UnorderedListOutlined />,
|
||||
label: <Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
|
||||
},
|
||||
{
|
||||
type: "divider",
|
||||
id: "header-jobs-divider2"
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "productionlist",
|
||||
id: "header-production-list",
|
||||
icon: <ScheduleOutlined />,
|
||||
label: <Link to="/manage/production/list">{t("menus.header.productionlist")}</Link>
|
||||
},
|
||||
|
||||
{
|
||||
key: "productionboard",
|
||||
id: "header-production-board",
|
||||
icon: <Icon component={BsKanban} />,
|
||||
icon: <BsKanban />,
|
||||
label: (
|
||||
<Link to="/manage/production/board">
|
||||
<LockWrapper featureName="visualboard" bodyshop={bodyshop}>
|
||||
@@ -421,11 +435,7 @@ function Header({
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
|
||||
{
|
||||
type: "divider",
|
||||
id: "header-jobs-divider3"
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "scoreboard",
|
||||
id: "header-scoreboard",
|
||||
@@ -442,8 +452,8 @@ function Header({
|
||||
},
|
||||
{
|
||||
key: "customers",
|
||||
icon: <UserOutlined />,
|
||||
id: "header-customers",
|
||||
icon: <UserOutlined />,
|
||||
label: t("menus.header.customers"),
|
||||
children: [
|
||||
{
|
||||
@@ -508,7 +518,6 @@ function Header({
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
...(accountingChildren.length > 0
|
||||
? [
|
||||
{
|
||||
@@ -526,7 +535,6 @@ function Header({
|
||||
icon: <PhoneOutlined />,
|
||||
label: <Link to="/manage/phonebook">{t("menus.header.phonebook")}</Link>
|
||||
},
|
||||
|
||||
{
|
||||
key: "temporarydocs",
|
||||
id: "header-temporarydocs",
|
||||
@@ -539,7 +547,6 @@ function Header({
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
|
||||
{
|
||||
key: "tasks",
|
||||
id: "tasks",
|
||||
@@ -551,12 +558,7 @@ function Header({
|
||||
id: "header-create-task",
|
||||
icon: <PlusCircleOutlined />,
|
||||
label: t("menus.header.create_task"),
|
||||
onClick: () => {
|
||||
setTaskUpsertContext({
|
||||
actions: {},
|
||||
context: {}
|
||||
});
|
||||
}
|
||||
onClick: () => setTaskUpsertContext({ actions: {}, context: {} })
|
||||
},
|
||||
{
|
||||
key: "mytasks",
|
||||
@@ -581,7 +583,7 @@ function Header({
|
||||
{
|
||||
key: "shop",
|
||||
id: "header-shop",
|
||||
icon: <Icon component={GiSettingsKnobs} />,
|
||||
icon: <GiSettingsKnobs />,
|
||||
label: <Link to="/manage/shop?tab=info">{t("menus.header.shop_config")}</Link>
|
||||
},
|
||||
{
|
||||
@@ -599,24 +601,18 @@ function Header({
|
||||
id: "header-reportcenter",
|
||||
icon: <BarChartOutlined />,
|
||||
label: t("menus.header.reportcenter"),
|
||||
onClick: () => {
|
||||
setReportCenterContext({
|
||||
actions: {},
|
||||
context: {}
|
||||
});
|
||||
}
|
||||
onClick: () => setReportCenterContext({ actions: {}, context: {} })
|
||||
},
|
||||
{
|
||||
key: "shop-vendors",
|
||||
id: "header-shop-vendors",
|
||||
icon: <Icon component={IoBusinessOutline} />,
|
||||
icon: <IoBusinessOutline />,
|
||||
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
|
||||
},
|
||||
|
||||
{
|
||||
key: "shop-csi",
|
||||
id: "header-shop-csi",
|
||||
icon: <Icon component={RiSurveyLine} />,
|
||||
icon: <RiSurveyLine />,
|
||||
label: (
|
||||
<Link to="/manage/shop/csi">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
@@ -627,14 +623,27 @@ function Header({
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "recent",
|
||||
id: "header-recent",
|
||||
icon: <ClockCircleFilled />,
|
||||
label: t("menus.header.recent"),
|
||||
children: recentItems.map((i, idx) => ({
|
||||
key: idx,
|
||||
id: `header-recent-${idx}`,
|
||||
label: <Link to={i.url}>{i.label}</Link>
|
||||
}))
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
label: currentUser.displayName || currentUser.email || t("general.labels.unknown"),
|
||||
id: "header-user",
|
||||
icon: <UserOutlined />,
|
||||
label: t("menus.currentuser.profile"),
|
||||
children: [
|
||||
{
|
||||
key: "signout",
|
||||
id: "header-signout",
|
||||
icon: <Icon component={FiLogOut} />,
|
||||
icon: <FiLogOut />,
|
||||
danger: true,
|
||||
label: t("user.actions.signout"),
|
||||
onClick: () => signOutStart()
|
||||
@@ -642,33 +651,25 @@ function Header({
|
||||
{
|
||||
key: "help",
|
||||
id: "header-help",
|
||||
icon: <Icon component={QuestionCircleFilled} />,
|
||||
icon: <QuestionCircleFilled />,
|
||||
label: t("menus.header.help"),
|
||||
onClick: () => {
|
||||
window.open("https://help.imex.online/", "_blank");
|
||||
}
|
||||
onClick: () => window.open("https://help.imex.online/", "_blank")
|
||||
},
|
||||
...(InstanceRenderManager({
|
||||
imex: true,
|
||||
rome: false
|
||||
})
|
||||
...(InstanceRenderManager({ imex: true, rome: false })
|
||||
? [
|
||||
{
|
||||
key: "rescue",
|
||||
id: "header-rescue",
|
||||
icon: <Icon component={CarFilled} />,
|
||||
icon: <CarFilled />,
|
||||
label: t("menus.header.rescueme"),
|
||||
onClick: () => {
|
||||
window.open("https://imexrescue.com/", "_blank");
|
||||
}
|
||||
onClick: () => window.open("https://imexrescue.com/", "_blank")
|
||||
}
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
key: "shiftclock",
|
||||
id: "header-shiftclock",
|
||||
icon: <Icon component={GiPlayerTime} />,
|
||||
icon: <GiPlayerTime />,
|
||||
label: (
|
||||
<Link to="/manage/shiftclock">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
@@ -677,64 +678,79 @@ function Header({
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
|
||||
{
|
||||
key: "profile",
|
||||
id: "header-profile",
|
||||
icon: <UserOutlined />,
|
||||
label: <Link to="/manage/profile">{t("menus.currentuser.profile")}</Link>
|
||||
}
|
||||
// {
|
||||
// key: 'langselecter',
|
||||
// label: t("menus.currentuser.languageselector"),
|
||||
// children: [
|
||||
// {
|
||||
// key: 'en-US',
|
||||
// label: t("general.languages.english"),
|
||||
// onClick: () => {
|
||||
// window.location.href = "/?lang=en-US";
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// key: 'fr-CA',
|
||||
// label: t("general.languages.french"),
|
||||
// onClick: () => {
|
||||
// window.location.href = "/?lang=fr-CA";
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// key: 'es-MX',
|
||||
// label: t("general.languages.spanish"),
|
||||
// onClick: () => {
|
||||
// window.location.href = "/?lang=es-MX";
|
||||
// }
|
||||
// },
|
||||
// ]
|
||||
// },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "recent",
|
||||
icon: <ClockCircleFilled />,
|
||||
id: "header-recent",
|
||||
children: recentItems.map((i, idx) => ({
|
||||
key: idx,
|
||||
id: `header-recent-${idx}`,
|
||||
label: <Link to={i.url}>{i.label}</Link>
|
||||
}))
|
||||
}
|
||||
];
|
||||
|
||||
// Notifications item (always on the right)
|
||||
const notificationItem = scenarioNotificationsOn
|
||||
? [
|
||||
{
|
||||
key: "notifications",
|
||||
id: "header-notifications",
|
||||
icon: unreadLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<Badge offset={[8, 0]} size="small" count={unreadCount}>
|
||||
<BellFilled />
|
||||
</Badge>
|
||||
),
|
||||
onClick: handleNotificationClick
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Layout.Header>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme={"dark"}
|
||||
selectedKeys={[selectedHeader]}
|
||||
onClick={handleMenuClick}
|
||||
subMenuCloseDelay={0.3}
|
||||
items={menuItems}
|
||||
/>
|
||||
<Layout.Header style={{ padding: 0, background: "#001529" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
selectedKeys={[selectedHeader]}
|
||||
onClick={handleMenuClick}
|
||||
subMenuCloseDelay={0.3}
|
||||
items={leftMenuItems}
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
minWidth: 0,
|
||||
overflowX: "auto",
|
||||
borderBottom: "none",
|
||||
background: "transparent"
|
||||
}}
|
||||
/>
|
||||
{scenarioNotificationsOn && (
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
selectedKeys={[selectedHeader]}
|
||||
onClick={handleMenuClick}
|
||||
subMenuCloseDelay={0.3}
|
||||
items={notificationItem}
|
||||
style={{ flex: "0 0 auto", minWidth: 0, borderBottom: "none", background: "transparent" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{scenarioNotificationsOn && (
|
||||
<NotificationCenterContainer
|
||||
visible={notificationVisible}
|
||||
onClose={() => setNotificationVisible(false)}
|
||||
unreadCount={unreadCount}
|
||||
/>
|
||||
)}
|
||||
</Layout.Header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
import { connect } from "react-redux";
|
||||
import HeaderComponent from "./header.component";
|
||||
|
||||
// const mapDispatchToProps = (dispatch) => ({
|
||||
// setUserLanguage: (language) => dispatch(setUserLanguage(language))
|
||||
// });
|
||||
|
||||
// setUserLanguage was removed from signature because it is not used in the component, and it is throwing a deprecation warning
|
||||
export function HeaderContainer() {
|
||||
// Commented out the handleMenuClick function because it is not used in the component, and it is throwing a deprecation warning
|
||||
|
||||
/* const handleMenuClick = (e) => {
|
||||
if (e.item.props.actiontype === "lang-select") {
|
||||
i18next.changeLanguage(e.key, (err, t) => {
|
||||
if (err) {
|
||||
logImEXEvent("language_change_error", { error: err });
|
||||
|
||||
return console.log("Error encountered when changing languages.", err);
|
||||
}
|
||||
logImEXEvent("language_change", { language: e.key });
|
||||
|
||||
setUserLanguage(e.key);
|
||||
});
|
||||
}
|
||||
};*/
|
||||
// return <HeaderComponent handleMenuClick={handleMenuClick} />;
|
||||
|
||||
return <HeaderComponent />;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import { useMutation } from "@apollo/client";
|
||||
import { Button, Divider, Dropdown, Form, Input, Popover, Select, Space } from "antd";
|
||||
import parsePhoneNumber from "libphonenumber-js";
|
||||
import queryString from "query-string";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
|
||||
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
@@ -51,7 +51,7 @@ export function ScheduleEventComponent({
|
||||
const searchParams = queryString.parse(useLocation().search);
|
||||
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
|
||||
const [title, setTitle] = useState(event.title);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
const notification = useNotification();
|
||||
|
||||
const blockContent = (
|
||||
|
||||
@@ -216,7 +216,7 @@ export function JobCloseRoGuardContainer({ job, jobRO, bodyshop, form }) {
|
||||
</Form.Item>
|
||||
</Collapse.Panel>
|
||||
|
||||
<Collapse.Panel header={t("jobs.labels.performance")}>
|
||||
<Collapse.Panel key="job-performance" header={t("jobs.labels.performance")}>
|
||||
<Row gutter={[32, 32]}>
|
||||
<Col className="ro-guard-col" span={24}>
|
||||
<JobCloseRoGuardTtLifecycle job={job} />
|
||||
|
||||
@@ -21,6 +21,8 @@ import JobDetailCardsInsuranceComponent from "./job-detail-cards.insurance.compo
|
||||
import JobDetailCardsNotesComponent from "./job-detail-cards.notes.component";
|
||||
import JobDetailCardsPartsComponent from "./job-detail-cards.parts.component";
|
||||
import JobDetailCardsTotalsComponent from "./job-detail-cards.totals.component";
|
||||
import JobWatcherToggleContainer from "../job-watcher-toggle/job-watcher-toggle.container.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
@@ -37,6 +39,7 @@ const span = {
|
||||
};
|
||||
|
||||
export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
|
||||
const { scenarioNotificationsOn } = useSocket();
|
||||
const selectedBreakpoint = Object.entries(Grid.useBreakpoint())
|
||||
.filter((screen) => !!screen[1])
|
||||
.slice(-1)[0];
|
||||
@@ -78,7 +81,12 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
|
||||
{data ? (
|
||||
<Card
|
||||
title={
|
||||
<Link to={`/manage/jobs/${data.jobs_by_pk.id}`}>{data.jobs_by_pk.ro_number || t("general.labels.na")}</Link>
|
||||
<Space>
|
||||
{scenarioNotificationsOn && <JobWatcherToggleContainer job={data.jobs_by_pk} />}
|
||||
<Link to={`/manage/jobs/${data.jobs_by_pk.id}`}>
|
||||
{data.jobs_by_pk.ro_number || t("general.labels.na")}
|
||||
</Link>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space wrap>
|
||||
@@ -122,7 +130,11 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
|
||||
</Col>
|
||||
{!bodyshop.uselocalmediaserver && (
|
||||
<Col {...span}>
|
||||
<JobDetailCardsDocumentsComponent loading={loading} data={data ? data.jobs_by_pk : null} bodyshop={bodyshop} />
|
||||
<JobDetailCardsDocumentsComponent
|
||||
loading={loading}
|
||||
data={data ? data.jobs_by_pk : null}
|
||||
bodyshop={bodyshop}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
<Col {...span}>
|
||||
|
||||
@@ -69,7 +69,7 @@ export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
|
||||
<Card title="DEVELOPMENT USE ONLY">
|
||||
<JobCalculateTotals job={job} disabled={jobRO} />
|
||||
<Collapse>
|
||||
<Collapse.Panel header="JSON Tree Totals">
|
||||
<Collapse.Panel key="json-totals" header="JSON Tree Totals">
|
||||
<div>
|
||||
<pre>
|
||||
{JSON.stringify(
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from "react";
|
||||
import { EyeFilled, EyeOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { Avatar, Button, Divider, List, Popover, Select, Tooltip, Typography } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmployeeSearchSelectComponent from "../../components/employee-search-select/employee-search-select.component.jsx";
|
||||
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component.jsx";
|
||||
import { BiSolidTrash } from "react-icons/bi";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function JobWatcherToggleComponent({
|
||||
jobWatchers,
|
||||
isWatching,
|
||||
watcherLoading,
|
||||
adding,
|
||||
removing,
|
||||
open,
|
||||
setOpen,
|
||||
selectedWatcher,
|
||||
setSelectedWatcher,
|
||||
selectedTeam,
|
||||
bodyshop,
|
||||
Enhanced_Payroll,
|
||||
handleToggleSelf,
|
||||
handleRemoveWatcher,
|
||||
handleWatcherSelect,
|
||||
handleTeamSelect
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleRenderItem = (watcher) => {
|
||||
// Check if watcher is defined and has user_email
|
||||
if (!watcher || !watcher.user_email) {
|
||||
return null; // Skip rendering invalid watchers
|
||||
}
|
||||
|
||||
const employee = bodyshop?.employees?.find((e) => e.user_email === watcher.user_email);
|
||||
const displayName = employee ? `${employee.first_name} ${employee.last_name}` : watcher.user_email;
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button
|
||||
type="default"
|
||||
danger
|
||||
size="medium"
|
||||
icon={<BiSolidTrash />}
|
||||
onClick={() => handleRemoveWatcher(watcher.user_email)}
|
||||
disabled={adding || removing} // Optional: Disable button during mutations
|
||||
>
|
||||
{t("notifications.actions.remove")}
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar icon={<UserOutlined />} />}
|
||||
title={<Text>{displayName}</Text>}
|
||||
description={watcher.user_email}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const popoverContent = (
|
||||
<div style={{ width: "30em" }}>
|
||||
<List>
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button
|
||||
type={isWatching ? "primary" : "default"}
|
||||
danger={!isWatching}
|
||||
icon={isWatching ? <EyeOutlined /> : <EyeFilled />}
|
||||
size="medium"
|
||||
onClick={handleToggleSelf}
|
||||
loading={adding || removing}
|
||||
>
|
||||
{isWatching ? t("notifications.labels.unwatch") : t("notifications.labels.watch")}
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta>
|
||||
<Text type="secondary" style={{ marginBottom: 8, display: "block" }}>
|
||||
{t("notifications.labels.watching-issue")}
|
||||
</Text>
|
||||
</List.Item.Meta>
|
||||
</List.Item>
|
||||
</List>
|
||||
{watcherLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : jobWatchers && jobWatchers.length > 0 ? (
|
||||
<List dataSource={jobWatchers} renderItem={handleRenderItem} />
|
||||
) : (
|
||||
<Text type="secondary">{t("notifications.labels.no-watchers")}</Text>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Text type="secondary">{t("notifications.labels.add-watchers")}</Text>
|
||||
<EmployeeSearchSelectComponent
|
||||
style={{ minWidth: "100%" }}
|
||||
options={
|
||||
bodyshop?.employees?.filter((e) =>
|
||||
jobWatchers.every((w) => w.user_email !== e.user_email && e.active && e.user_email)
|
||||
) || []
|
||||
}
|
||||
placeholder={t("notifications.labels.employee-search")}
|
||||
value={selectedWatcher}
|
||||
onChange={(value) => {
|
||||
setSelectedWatcher(value);
|
||||
handleWatcherSelect(value);
|
||||
}}
|
||||
/>
|
||||
{Enhanced_Payroll && bodyshop?.employee_teams?.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Text type="secondary">{t("notifications.labels.add-watchers-team")}</Text>
|
||||
<Select
|
||||
showSearch
|
||||
style={{ minWidth: "100%" }}
|
||||
placeholder={t("notifications.labels.teams-search")}
|
||||
value={selectedTeam}
|
||||
onChange={handleTeamSelect}
|
||||
options={
|
||||
bodyshop?.employee_teams?.map((team) => {
|
||||
const teamMembers = team.employee_team_members
|
||||
.map((member) => {
|
||||
const employee = bodyshop?.employees?.find((e) => e.id === member.employeeid);
|
||||
return employee?.user_email && employee?.active ? employee.user_email : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
return {
|
||||
value: JSON.stringify(teamMembers),
|
||||
label: team.name
|
||||
};
|
||||
}) || []
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover placement="rightTop" content={popoverContent} trigger="click" open={open} onOpenChange={setOpen}>
|
||||
<Tooltip title={t("notifications.tooltips.job-watchers")}>
|
||||
<Button
|
||||
shape="circle"
|
||||
type={isWatching ? "primary" : "default"}
|
||||
icon={isWatching ? <EyeFilled /> : <EyeOutlined />}
|
||||
loading={watcherLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../graphql/jobs.queries.js";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
|
||||
const {
|
||||
treatments: { Enhanced_Payroll }
|
||||
} = useSplitTreatments({
|
||||
attributes: {},
|
||||
names: ["Enhanced_Payroll"],
|
||||
splitKey: bodyshop && bodyshop.imexshopid
|
||||
});
|
||||
|
||||
const userEmail = currentUser.email;
|
||||
const jobid = job.id;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedWatcher, setSelectedWatcher] = useState(null);
|
||||
const [selectedTeam, setSelectedTeam] = useState(null);
|
||||
|
||||
// Fetch current watchers with refetch capability
|
||||
const {
|
||||
data: watcherData,
|
||||
loading: watcherLoading,
|
||||
refetch
|
||||
} = useQuery(GET_JOB_WATCHERS, {
|
||||
variables: { jobid },
|
||||
fetchPolicy: "cache-and-network" // Ensure fresh data from server
|
||||
});
|
||||
|
||||
// Refetch jobWatchers when the popover opens (open changes to true)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
refetch().catch((err) =>
|
||||
console.error(`Something went wrong fetching Notification Watchers on popover open: ${err?.message}`, {
|
||||
stack: err?.stack
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [open, refetch]);
|
||||
|
||||
const jobWatchers = useMemo(() => (watcherData?.job_watchers ? [...watcherData.job_watchers] : []), [watcherData]);
|
||||
const isWatching = jobWatchers.some((w) => w.user_email === userEmail);
|
||||
|
||||
const [addWatcher, { loading: adding }] = useMutation(ADD_JOB_WATCHER, {
|
||||
onCompleted: () =>
|
||||
refetch().catch((err) =>
|
||||
console.error(`Something went wrong fetching Notification Watchers after add: ${err?.message}`, {
|
||||
stack: err?.stack
|
||||
})
|
||||
),
|
||||
onError: (err) => {
|
||||
if (err.graphQLErrors && err.graphQLErrors.length > 0) {
|
||||
const errorMessage = err.graphQLErrors[0].message;
|
||||
if (
|
||||
errorMessage.includes("Uniqueness violation") ||
|
||||
errorMessage.includes("idx_job_watchers_jobid_user_email_unique")
|
||||
) {
|
||||
console.warn("Watcher already exists for this job and user.");
|
||||
refetch().catch((err) =>
|
||||
console.error(
|
||||
`Something went wrong fetching Notification Watchers after uniqueness violation: ${err?.message}`,
|
||||
{ stack: err?.stack }
|
||||
)
|
||||
); // Sync with server to ensure UI reflects actual state
|
||||
} else {
|
||||
console.error(`Error adding job watcher: ${errorMessage}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`Unexpected error adding job watcher: ${err.message || JSON.stringify(err)}`);
|
||||
}
|
||||
},
|
||||
update(cache, { data }) {
|
||||
if (!data || !data.insert_job_watchers_one) {
|
||||
console.warn("No data or insert_job_watchers_one returned from mutation, skipping cache update.");
|
||||
refetch().catch((err) =>
|
||||
console.error(`Something went wrong updating Notification Watchers after add: ${err?.message}`, {
|
||||
stack: err?.stack
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const insert_job_watchers_one = data.insert_job_watchers_one;
|
||||
const existingData = cache.readQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid }
|
||||
});
|
||||
|
||||
cache.writeQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid },
|
||||
data: {
|
||||
...existingData,
|
||||
job_watchers: [...(existingData?.job_watchers || []), insert_job_watchers_one]
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const [removeWatcher, { loading: removing }] = useMutation(REMOVE_JOB_WATCHER, {
|
||||
onCompleted: () =>
|
||||
refetch().catch((err) =>
|
||||
console.error(`Something went wrong fetching Notification Watchers after remove: ${err?.message}`, {
|
||||
stack: err?.stack
|
||||
})
|
||||
), // Refetch to sync with server after success
|
||||
onError: (err) => console.error(`Error removing job watcher: ${err.message}`),
|
||||
update(cache, { data: { delete_job_watchers } }) {
|
||||
const existingData = cache.readQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid }
|
||||
});
|
||||
|
||||
const deletedWatcher = delete_job_watchers.returning[0];
|
||||
const updatedWatchers = deletedWatcher
|
||||
? (existingData?.job_watchers || []).filter((watcher) => watcher.user_email !== deletedWatcher.user_email)
|
||||
: existingData?.job_watchers || [];
|
||||
|
||||
cache.writeQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid },
|
||||
data: {
|
||||
...existingData,
|
||||
job_watchers: updatedWatchers
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleToggleSelf = useCallback(async () => {
|
||||
if (adding || removing) return;
|
||||
if (isWatching) {
|
||||
await removeWatcher({ variables: { jobid, userEmail } });
|
||||
} else {
|
||||
await addWatcher({ variables: { jobid, userEmail } });
|
||||
}
|
||||
}, [isWatching, addWatcher, removeWatcher, jobid, userEmail, adding, removing]);
|
||||
|
||||
const handleRemoveWatcher = useCallback(
|
||||
async (email) => {
|
||||
if (removing) return;
|
||||
await removeWatcher({ variables: { jobid, userEmail: email } });
|
||||
},
|
||||
[removeWatcher, jobid, removing]
|
||||
);
|
||||
|
||||
const handleWatcherSelect = useCallback(
|
||||
async (selectedUser) => {
|
||||
if (adding || removing) return;
|
||||
const employee = bodyshop.employees.find((e) => e.id === selectedUser);
|
||||
if (!employee) return;
|
||||
|
||||
const email = employee.user_email;
|
||||
const isAlreadyWatching = jobWatchers.some((w) => w.user_email === email);
|
||||
|
||||
if (isAlreadyWatching) {
|
||||
await handleRemoveWatcher(email);
|
||||
} else {
|
||||
await addWatcher({ variables: { jobid, userEmail: email } });
|
||||
}
|
||||
setSelectedWatcher(null);
|
||||
},
|
||||
[jobWatchers, addWatcher, handleRemoveWatcher, jobid, bodyshop, adding, removing]
|
||||
);
|
||||
|
||||
const handleTeamSelect = useCallback(
|
||||
async (team) => {
|
||||
if (adding) return;
|
||||
const selectedTeamMembers = JSON.parse(team);
|
||||
const newWatchers = selectedTeamMembers.filter(
|
||||
(email) => !jobWatchers.some((watcher) => watcher.user_email === email)
|
||||
);
|
||||
|
||||
if (newWatchers.length === 0) {
|
||||
console.warn("All selected team members are already watchers.");
|
||||
setSelectedTeam(null);
|
||||
return;
|
||||
}
|
||||
await Promise.all(newWatchers.map((email) => addWatcher({ variables: { jobid, userEmail: email } })));
|
||||
},
|
||||
[jobWatchers, addWatcher, jobid, adding]
|
||||
);
|
||||
|
||||
return (
|
||||
<JobWatcherToggleComponent
|
||||
jobWatchers={jobWatchers}
|
||||
isWatching={isWatching}
|
||||
watcherLoading={watcherLoading}
|
||||
adding={adding}
|
||||
removing={removing}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
selectedWatcher={selectedWatcher}
|
||||
setSelectedWatcher={setSelectedWatcher}
|
||||
selectedTeam={selectedTeam}
|
||||
setSelectedTeam={setSelectedTeam}
|
||||
bodyshop={bodyshop}
|
||||
Enhanced_Payroll={Enhanced_Payroll}
|
||||
handleToggleSelf={handleToggleSelf}
|
||||
handleRemoveWatcher={handleRemoveWatcher}
|
||||
handleWatcherSelect={handleWatcherSelect}
|
||||
handleTeamSelect={handleTeamSelect}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(JobWatcherToggleContainer);
|
||||
@@ -4,13 +4,12 @@ import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { Button, Card, Dropdown, Form, Input, Modal, Popconfirm, Popover, Select, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import parsePhoneNumber from "libphonenumber-js";
|
||||
import { useContext, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { auth, logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
|
||||
import { GET_CURRENT_QUESTIONSET_ID, INSERT_CSI } from "../../graphql/csi.queries";
|
||||
@@ -130,7 +129,7 @@ export function JobsDetailHeaderActions({
|
||||
const [updateJob] = useMutation(UPDATE_JOB);
|
||||
const [voidJob] = useMutation(VOID_JOB);
|
||||
const [cancelAllAppointments] = useMutation(CANCEL_APPOINTMENTS_BY_JOB_ID);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
const notification = useNotification();
|
||||
|
||||
const {
|
||||
|
||||
@@ -119,7 +119,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
|
||||
<DataLabel label={t("jobs.labels.contracts")}>
|
||||
{job.cccontracts.map((c, index) => (
|
||||
<Space key={c.id} wrap>
|
||||
<Link to={`/manage/courtesycars/contracts/${c.id}`}>
|
||||
<Link to={`/manage/courtesycars/contracts/${c.id}`}>
|
||||
{`${c.agreementnumber} - ${c.courtesycar.fleetnumber} ${c.courtesycar.year} ${c.courtesycar.make} ${c.courtesycar.model}`}
|
||||
{index !== job.cccontracts.length - 1 ? "," : null}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd";
|
||||
import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./notification-center.styles.scss";
|
||||
import day from "../../utils/day.js";
|
||||
import { forwardRef, useRef, useEffect } from "react";
|
||||
import { DateTimeFormat } from "../../utils/DateFormatter.jsx";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
/**
|
||||
* Notification Center Component
|
||||
* @type {React.ForwardRefExoticComponent<React.PropsWithoutRef<{readonly visible?: *, readonly onClose?: *, readonly notifications?: *, readonly loading?: *, readonly showUnreadOnly?: *, readonly toggleUnreadOnly?: *, readonly markAllRead?: *, readonly loadMore?: *, readonly onNotificationClick?: *, readonly unreadCount?: *}> & React.RefAttributes<unknown>>}
|
||||
*/
|
||||
const NotificationCenterComponent = forwardRef(
|
||||
(
|
||||
{
|
||||
visible,
|
||||
onClose,
|
||||
notifications,
|
||||
loading,
|
||||
showUnreadOnly,
|
||||
toggleUnreadOnly,
|
||||
markAllRead,
|
||||
loadMore,
|
||||
onNotificationClick,
|
||||
unreadCount
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const virtuosoRef = useRef(null);
|
||||
|
||||
// Scroll to top when showUnreadOnly changes
|
||||
useEffect(() => {
|
||||
if (virtuosoRef.current) {
|
||||
virtuosoRef.current.scrollToIndex({ index: 0, behavior: "smooth" });
|
||||
}
|
||||
}, [showUnreadOnly]);
|
||||
|
||||
const renderNotification = (index, notification) => {
|
||||
const handleClick = () => {
|
||||
if (!notification.read) {
|
||||
onNotificationClick(notification.id);
|
||||
}
|
||||
navigate(`/manage/jobs/${notification.jobid}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${notification.id}-${index}`}
|
||||
className={`notification-item ${notification.read ? "notification-read" : "notification-unread"}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Badge dot={!notification.read}>
|
||||
<div className="notification-content">
|
||||
<Title level={5} className="notification-title">
|
||||
<span className="ro-number">
|
||||
{t("notifications.labels.ro-number", { ro_number: notification.roNumber || t("general.labels.na") })}
|
||||
</span>
|
||||
<Text type="secondary" className="relative-time" title={DateTimeFormat(notification.created_at)}>
|
||||
{day(notification.created_at).fromNow()}
|
||||
</Text>
|
||||
</Title>
|
||||
<Text strong={!notification.read} className="notification-body">
|
||||
<ul>
|
||||
{notification.scenarioText.map((text, idx) => (
|
||||
<li key={`${notification.id}-${idx}`}>{text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Text>
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`notification-center ${visible ? "visible" : ""}`} ref={ref}>
|
||||
<div className="notification-header">
|
||||
<Space direction="horizontal">
|
||||
<h3>{t("notifications.labels.notification-center")}</h3>
|
||||
{loading && <Spin spinning={loading} size="small"></Spin>}
|
||||
</Space>
|
||||
<div className="notification-controls">
|
||||
<Tooltip title={t("notifications.labels.show-unread-only")}>
|
||||
<Space size={4} align="center" className="notification-toggle">
|
||||
{showUnreadOnly ? (
|
||||
<EyeFilled className="notification-toggle-icon" />
|
||||
) : (
|
||||
<EyeOutlined className="notification-toggle-icon" />
|
||||
)}
|
||||
<Switch checked={showUnreadOnly} onChange={(checked) => toggleUnreadOnly(checked)} size="small" />
|
||||
</Space>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("notifications.labels.mark-all-read")}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={!unreadCount ? <CheckCircleFilled /> : <CheckCircleOutlined />}
|
||||
onClick={markAllRead}
|
||||
disabled={!unreadCount}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
style={{ height: "400px", width: "100%" }}
|
||||
data={notifications}
|
||||
totalCount={notifications.length}
|
||||
endReached={loadMore}
|
||||
itemContent={renderNotification}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default NotificationCenterComponent;
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { connect } from "react-redux";
|
||||
import NotificationCenterComponent from "./notification-center.component";
|
||||
import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries";
|
||||
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
import day from "../../utils/day.js";
|
||||
|
||||
// This will be used to poll for notifications when the socket is disconnected
|
||||
const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Notification Center Container
|
||||
* @param visible
|
||||
* @param onClose
|
||||
* @param bodyshop
|
||||
* @param unreadCount
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }) => {
|
||||
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket();
|
||||
const notificationRef = useRef(null);
|
||||
|
||||
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||
|
||||
const baseWhereClause = useMemo(() => {
|
||||
return { associationid: { _eq: userAssociationId } };
|
||||
}, [userAssociationId]);
|
||||
|
||||
const whereClause = useMemo(() => {
|
||||
return showUnreadOnly ? { ...baseWhereClause, read: { _is_null: true } } : baseWhereClause;
|
||||
}, [baseWhereClause, showUnreadOnly]);
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchMore,
|
||||
loading: queryLoading,
|
||||
refetch
|
||||
} = useQuery(GET_NOTIFICATIONS, {
|
||||
variables: {
|
||||
limit: INITIAL_NOTIFICATIONS,
|
||||
offset: 0,
|
||||
where: whereClause
|
||||
},
|
||||
fetchPolicy: "cache-and-network",
|
||||
notifyOnNetworkStatusChange: true,
|
||||
pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
|
||||
skip: !userAssociationId,
|
||||
onError: (err) => {
|
||||
console.error(`Error polling Notifications: ${err?.message || ""}`);
|
||||
setTimeout(() => refetch(), day.duration(2, "seconds").asMilliseconds());
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
// Prevent open + close behavior from the header
|
||||
if (event.target.closest("#header-notifications")) return;
|
||||
if (visible && notificationRef.current && !notificationRef.current.contains(event.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [visible, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.notifications) {
|
||||
const processedNotifications = data.notifications
|
||||
.map((notif) => {
|
||||
let scenarioText;
|
||||
let scenarioMeta;
|
||||
try {
|
||||
scenarioText = notif.scenario_text ? JSON.parse(notif.scenario_text) : [];
|
||||
scenarioMeta = notif.scenario_meta ? JSON.parse(notif.scenario_meta) : {};
|
||||
} catch (e) {
|
||||
console.error("Error parsing JSON for notification:", notif.id, e);
|
||||
scenarioText = [notif.fcm_text || "Invalid notification data"];
|
||||
scenarioMeta = {};
|
||||
}
|
||||
if (!Array.isArray(scenarioText)) scenarioText = [scenarioText];
|
||||
const roNumber = notif.job.ro_number;
|
||||
if (!Array.isArray(scenarioMeta)) scenarioMeta = [scenarioMeta];
|
||||
return {
|
||||
id: notif.id,
|
||||
jobid: notif.jobid,
|
||||
associationid: notif.associationid,
|
||||
scenarioText,
|
||||
scenarioMeta,
|
||||
roNumber,
|
||||
created_at: notif.created_at,
|
||||
read: notif.read,
|
||||
__typename: notif.__typename
|
||||
};
|
||||
})
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
setNotifications(processedNotifications);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!queryLoading && data?.notifications.length) {
|
||||
setIsLoading(true); // Show spinner during fetchMore
|
||||
fetchMore({
|
||||
variables: { offset: data.notifications.length, where: whereClause },
|
||||
updateQuery: (prev, { fetchMoreResult }) => {
|
||||
if (!fetchMoreResult) return prev;
|
||||
return {
|
||||
notifications: [...prev.notifications, ...fetchMoreResult.notifications]
|
||||
};
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Fetch more error:", err);
|
||||
})
|
||||
.finally(() => setIsLoading(false)); // Hide spinner when done
|
||||
}
|
||||
}, [data?.notifications?.length, fetchMore, queryLoading, whereClause]);
|
||||
|
||||
const handleToggleUnreadOnly = (value) => {
|
||||
setShowUnreadOnly(value);
|
||||
};
|
||||
|
||||
const handleMarkAllRead = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
markAllNotificationsRead()
|
||||
.then(() => {
|
||||
const timestamp = new Date().toISOString();
|
||||
setNotifications((prev) => {
|
||||
const updatedNotifications = prev.map((notif) =>
|
||||
notif.read === null && notif.associationid === userAssociationId
|
||||
? {
|
||||
...notif,
|
||||
read: timestamp
|
||||
}
|
||||
: notif
|
||||
);
|
||||
// Filter out read notifications if in unread only mode
|
||||
return showUnreadOnly ? updatedNotifications.filter((notif) => !notif.read) : updatedNotifications;
|
||||
});
|
||||
})
|
||||
.catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [markAllNotificationsRead, userAssociationId, showUnreadOnly]);
|
||||
|
||||
const handleNotificationClick = useCallback(
|
||||
(notificationId) => {
|
||||
setIsLoading(true);
|
||||
markNotificationRead({ variables: { id: notificationId } })
|
||||
.then(() => {
|
||||
const timestamp = new Date().toISOString();
|
||||
setNotifications((prev) => {
|
||||
const updatedNotifications = prev.map((notif) =>
|
||||
notif.id === notificationId && !notif.read ? { ...notif, read: timestamp } : notif
|
||||
);
|
||||
// Filter out the read notification if in unread only mode
|
||||
return showUnreadOnly ? updatedNotifications.filter((notif) => !notif.read) : updatedNotifications;
|
||||
});
|
||||
})
|
||||
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`))
|
||||
.finally(() => setIsLoading(false));
|
||||
},
|
||||
[markNotificationRead, showUnreadOnly]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && !isConnected) {
|
||||
setIsLoading(true);
|
||||
refetch()
|
||||
.catch((err) => console.error(`Error re-fetching notifications: ${err?.message || ""}`))
|
||||
.finally(() => setIsLoading(false));
|
||||
}
|
||||
}, [visible, isConnected, refetch]);
|
||||
|
||||
return (
|
||||
<NotificationCenterComponent
|
||||
ref={notificationRef}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
notifications={notifications}
|
||||
loading={isLoading}
|
||||
showUnreadOnly={showUnreadOnly}
|
||||
toggleUnreadOnly={handleToggleUnreadOnly}
|
||||
markAllRead={handleMarkAllRead}
|
||||
loadMore={loadMore}
|
||||
onNotificationClick={handleNotificationClick}
|
||||
unreadCount={unreadCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(NotificationCenterContainer);
|
||||
@@ -0,0 +1,175 @@
|
||||
.notification-center {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
right: 0;
|
||||
width: 400px;
|
||||
max-width: 400px;
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
overflow-x: hidden;
|
||||
|
||||
&.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
padding: 4px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #fafafa;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.notification-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
// Styles for the eye icon and switch (custom classes)
|
||||
.notification-toggle {
|
||||
align-items: center; // Ensure vertical alignment
|
||||
}
|
||||
|
||||
.notification-toggle-icon {
|
||||
font-size: 14px;
|
||||
color: #1677ff;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ant-switch {
|
||||
&.ant-switch-small {
|
||||
min-width: 28px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
|
||||
.ant-switch-handle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
&.ant-switch-checked {
|
||||
background-color: #1677ff;
|
||||
.ant-switch-handle {
|
||||
left: calc(100% - 14px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Styles for the "Mark All Read" button (restore original link button style)
|
||||
.ant-btn-link {
|
||||
padding: 0;
|
||||
color: #1677ff;
|
||||
|
||||
&:hover {
|
||||
color: #69b1ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #0958d9;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notification-read {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.notification-unread {
|
||||
background: #f5f5f5;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.ro-number {
|
||||
margin: 0;
|
||||
color: #1677ff;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.relative-time {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
margin-top: 4px;
|
||||
|
||||
.ant-typography {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-badge {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-alert {
|
||||
margin: 8px;
|
||||
background: #fff1f0;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
border: 1px solid #ffa39e;
|
||||
|
||||
.ant-alert-message {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
|
||||
import { Checkbox, Form } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
/**
|
||||
* ColumnHeaderCheckbox
|
||||
* @param channel
|
||||
* @param form
|
||||
* @param disabled
|
||||
* @param onHeaderChange
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Subscribe to all form values so that this component re-renders on changes.
|
||||
const formValues = Form.useWatch([], form) || {};
|
||||
|
||||
// Determine if all scenarios for this channel are checked.
|
||||
const allChecked =
|
||||
notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]);
|
||||
|
||||
const onChange = (e) => {
|
||||
const checked = e.target.checked;
|
||||
// Get current form values.
|
||||
const currentValues = form.getFieldsValue();
|
||||
// Update each scenario for this channel.
|
||||
const newValues = { ...currentValues };
|
||||
notificationScenarios.forEach((scenario) => {
|
||||
newValues[scenario] = { ...newValues[scenario], [channel]: checked };
|
||||
});
|
||||
// Update form values.
|
||||
form.setFieldsValue(newValues);
|
||||
// Manually mark the form as dirty.
|
||||
if (onHeaderChange) {
|
||||
onHeaderChange();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Checkbox onChange={onChange} checked={allChecked} disabled={disabled}>
|
||||
{t(`notifications.channels.${channel}`)}
|
||||
</Checkbox>
|
||||
);
|
||||
};
|
||||
|
||||
ColumnHeaderCheckbox.propTypes = {
|
||||
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
|
||||
form: PropTypes.object.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
onHeaderChange: PropTypes.func
|
||||
};
|
||||
|
||||
export default ColumnHeaderCheckbox;
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Card, Checkbox, Form, Space, Table } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import { QUERY_NOTIFICATION_SETTINGS, UPDATE_NOTIFICATION_SETTINGS } from "../../graphql/user.queries.js";
|
||||
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
|
||||
import PropTypes from "prop-types";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
|
||||
|
||||
/**
|
||||
* Notifications Settings Form
|
||||
* @param currentUser
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const NotificationSettingsForm = ({ currentUser }) => {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const [initialValues, setInitialValues] = useState({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const notification = useNotification();
|
||||
|
||||
// Fetch notification settings.
|
||||
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
variables: { email: currentUser.email },
|
||||
skip: !currentUser
|
||||
});
|
||||
|
||||
const [updateNotificationSettings, { loading: saving }] = useMutation(UPDATE_NOTIFICATION_SETTINGS);
|
||||
|
||||
// Populate form with fetched data.
|
||||
useEffect(() => {
|
||||
if (data?.associations?.length > 0) {
|
||||
const settings = data.associations[0].notification_settings || {};
|
||||
// Ensure each scenario has an object with { app, email, fcm }.
|
||||
const formattedValues = notificationScenarios.reduce((acc, scenario) => {
|
||||
acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false };
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
setInitialValues(formattedValues);
|
||||
form.setFieldsValue(formattedValues);
|
||||
setIsDirty(false); // Reset dirty state when new data loads.
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const handleSave = async (values) => {
|
||||
if (data?.associations?.length > 0) {
|
||||
const userId = data.associations[0].id;
|
||||
// Save the updated notification settings.
|
||||
const result = await updateNotificationSettings({ variables: { id: userId, ns: values } });
|
||||
if (!result?.errors) {
|
||||
notification.success({ message: t("notifications.labels.notification-settings-success") });
|
||||
setInitialValues(values);
|
||||
setIsDirty(false);
|
||||
} else {
|
||||
notification.error({ message: t("notifications.labels.notification-settings-failure") });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Mark the form as dirty on any manual change.
|
||||
const handleFormChange = () => {
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.setFieldsValue(initialValues);
|
||||
setIsDirty(false);
|
||||
};
|
||||
|
||||
if (error) return <AlertComponent type="error" message={error.message} />;
|
||||
if (loading) return <LoadingSpinner />;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t("notifications.labels.scenario"),
|
||||
dataIndex: "scenarioLabel",
|
||||
key: "scenario",
|
||||
render: (_, record) => t(`notifications.scenarios.${record.key}`),
|
||||
width: "90%"
|
||||
},
|
||||
{
|
||||
title: <ColumnHeaderCheckbox channel="app" form={form} onHeaderChange={() => setIsDirty(true)} />,
|
||||
dataIndex: "app",
|
||||
key: "app",
|
||||
align: "center",
|
||||
render: (_, record) => (
|
||||
<Form.Item name={[record.key, "app"]} valuePropName="checked" noStyle>
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: <ColumnHeaderCheckbox channel="email" form={form} onHeaderChange={() => setIsDirty(true)} />,
|
||||
dataIndex: "email",
|
||||
key: "email",
|
||||
align: "center",
|
||||
render: (_, record) => (
|
||||
<Form.Item name={[record.key, "email"]} valuePropName="checked" noStyle>
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
// TODO: Disabled for now until FCM is implemented.
|
||||
// {
|
||||
// title: <ColumnHeaderCheckbox channel="fcm" form={form} disabled onHeaderChange={() => setIsDirty(true)} />,
|
||||
// dataIndex: "fcm",
|
||||
// key: "fcm",
|
||||
// align: "center",
|
||||
// render: (_, record) => (
|
||||
// <Form.Item name={[record.key, "fcm"]} valuePropName="checked" noStyle>
|
||||
// <Checkbox disabled />
|
||||
// </Form.Item>
|
||||
// )
|
||||
// }
|
||||
];
|
||||
|
||||
const dataSource = notificationScenarios.map((scenario) => ({ key: scenario }));
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleSave}
|
||||
onValuesChange={handleFormChange}
|
||||
initialValues={initialValues}
|
||||
autoComplete="off"
|
||||
layout="vertical"
|
||||
>
|
||||
<Card
|
||||
title={t("notifications.labels.notificationscenarios")}
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="default" onClick={handleReset} disabled={!isDirty}>
|
||||
{t("general.actions.clear")}
|
||||
</Button>
|
||||
|
||||
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={saving}>
|
||||
{t("notifications.labels.save")}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" />
|
||||
</Card>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
NotificationSettingsForm.propTypes = {
|
||||
currentUser: PropTypes.shape({
|
||||
email: PropTypes.string.isRequired
|
||||
}).isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(NotificationSettingsForm);
|
||||
@@ -2,15 +2,15 @@ import { CopyFilled } from "@ant-design/icons";
|
||||
import { Button, Form, message, Popover, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import Dinero from "dinero.js";
|
||||
import { parsePhoneNumber } from "libphonenumber-js";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { parsePhoneNumberWithError, ParseError } from "libphonenumber-js";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -29,22 +29,34 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [paymentLink, setPaymentLink] = useState(null);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
|
||||
const handleFinish = async ({ amount }) => {
|
||||
setLoading(true);
|
||||
let p;
|
||||
try {
|
||||
p = parsePhoneNumber(job.ownr_ph1 || "", "CA");
|
||||
// Updated to use parsePhoneNumberWithError
|
||||
p = parsePhoneNumberWithError(job.ownr_ph1 || "", "CA");
|
||||
} catch (error) {
|
||||
console.log("Unable to parse phone number");
|
||||
if (error instanceof ParseError) {
|
||||
// Handle specific parsing errors
|
||||
console.log(`Phone number parsing failed: ${error.message}`);
|
||||
} else {
|
||||
// Handle other unexpected errors
|
||||
console.log("Unexpected error while parsing phone number:", error);
|
||||
}
|
||||
}
|
||||
setLoading(true);
|
||||
const response = await axios.post("/intellipay/generate_payment_url", {
|
||||
bodyshop,
|
||||
amount: amount,
|
||||
account: job.ro_number,
|
||||
comment: btoa(JSON.stringify({ payments: [{ jobid: job.id, amount }], userEmail: currentUser.email }))
|
||||
comment: btoa(
|
||||
JSON.stringify({
|
||||
payments: [{ jobid: job.id, amount }],
|
||||
userEmail: currentUser.email
|
||||
})
|
||||
)
|
||||
});
|
||||
setLoading(false);
|
||||
setPaymentLink(response.data.shorUrl);
|
||||
@@ -106,7 +118,20 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
|
||||
</Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const p = parsePhoneNumber(job.ownr_ph1, "CA");
|
||||
let p;
|
||||
try {
|
||||
// Updated second instance of phone parsing
|
||||
p = parsePhoneNumberWithError(job.ownr_ph1, "CA");
|
||||
} catch (error) {
|
||||
if (error instanceof ParseError) {
|
||||
// Handle specific parsing errors
|
||||
console.log(`Phone number parsing failed: ${error.message}`);
|
||||
} else {
|
||||
// Handle other unexpected errors
|
||||
console.log("Unexpected error while parsing phone number:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
openChatByPhone({
|
||||
phone_num: p.formatInternational(),
|
||||
jobid: job.id,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useApolloClient } from "@apollo/client";
|
||||
import { Button, Skeleton, Space } from "antd";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -12,7 +12,7 @@ import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -22,7 +22,7 @@ const mapStateToProps = createStructuredSelector({
|
||||
function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionType = "direct" }) {
|
||||
const fired = useRef(false);
|
||||
const client = useApolloClient();
|
||||
const { socket } = useContext(SocketContext); // Get the socket from context
|
||||
const { socket } = useSocket();
|
||||
const reconnectTimeout = useRef(null); // To store the reconnect timeout
|
||||
const disconnectTime = useRef(null); // To track disconnection time
|
||||
const acceptableReconnectTime = 2000; // 2 seconds threshold
|
||||
|
||||
@@ -27,6 +27,8 @@ import ScoreboardAddButton from "../job-scoreboard-add-button/job-scoreboard-add
|
||||
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
|
||||
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
|
||||
import ProductionRemoveButton from "../production-remove-button/production-remove-button.component";
|
||||
import JobWatcherToggleContainer from "../job-watcher-toggle/job-watcher-toggle.container.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -41,6 +43,7 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
|
||||
const search = queryString.parse(useLocation().search);
|
||||
const history = useNavigate();
|
||||
const { selected } = search;
|
||||
const { scenarioNotificationsOn } = useSocket();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const theJob = jobs.find((j) => j.id === selected) || {};
|
||||
@@ -60,7 +63,12 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
|
||||
<Drawer
|
||||
title={
|
||||
<PageHeader
|
||||
title={theJob.ro_number}
|
||||
title={
|
||||
<Space>
|
||||
{!technician && scenarioNotificationsOn && <JobWatcherToggleContainer job={theJob} />}
|
||||
{theJob.ro_number}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space wrap>
|
||||
{!technician ? <ProductionRemoveButton jobId={theJob.id} /> : null}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
||||
import React, { useContext, useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
QUERY_EXACT_JOB_IN_PRODUCTION,
|
||||
QUERY_EXACT_JOBS_IN_PRODUCTION,
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
import ProductionListTable from "./production-list-table.component";
|
||||
import _ from "lodash";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
|
||||
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
|
||||
const client = useApolloClient();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
const [joblist, setJoblist] = useState([]);
|
||||
const reconnectTimeout = useRef(null); // To store the reconnect timeout
|
||||
const disconnectTime = useRef(null); // To store the time of disconnection
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function ProductionRemoveButton({ jobId }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Button loading={loading} onClick={handleRemoveFromProd} type={"danger"}>
|
||||
<Button loading={loading} onClick={handleRemoveFromProd} type="default" danger>
|
||||
{t("production.actions.remove")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button, Card, Col, Form, Input } from "antd";
|
||||
import { LockOutlined } from "@ant-design/icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -9,6 +8,8 @@ import { selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import NotificationSettingsForm from "../notification-settings/notification-settings-form.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser
|
||||
@@ -22,6 +23,7 @@ export default connect(
|
||||
)(function ProfileMyComponent({ currentUser, updateUserDetails }) {
|
||||
const { t } = useTranslation();
|
||||
const notification = useNotification();
|
||||
const { scenarioNotificationsOn } = useSocket();
|
||||
|
||||
const handleFinish = (values) => {
|
||||
logImEXEvent("profile_update");
|
||||
@@ -117,6 +119,11 @@ export default connect(
|
||||
</Card>
|
||||
</Form>
|
||||
</Col>
|
||||
{scenarioNotificationsOn && (
|
||||
<Col span={24}>
|
||||
<NotificationSettingsForm />
|
||||
</Col>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AlertOutlined } from "@ant-design/icons";
|
||||
import { Alert, Button, Col, Row, Space } from "antd";
|
||||
import i18n from "i18next";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -81,8 +81,7 @@ export function UpdateAlert({ updateAvailable }) {
|
||||
imex: "$t(titles.imexonline)",
|
||||
rome: "$t(titles.romeonline)"
|
||||
})
|
||||
}),
|
||||
placement: "bottomRight"
|
||||
})
|
||||
});
|
||||
}
|
||||
if (needRefresh && timerStarted && timeLeft <= 0) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// NotificationProvider.jsx
|
||||
import React, { createContext, useContext } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { notification } from "antd";
|
||||
|
||||
/**
|
||||
@@ -22,7 +21,11 @@ export const useNotification = () => {
|
||||
* - Provide `api` via the NotificationContext.
|
||||
*/
|
||||
export const NotificationProvider = ({ children }) => {
|
||||
const [api, contextHolder] = notification.useNotification();
|
||||
const [api, contextHolder] = notification.useNotification({
|
||||
placement: "bottomRight",
|
||||
bottom: 70,
|
||||
showProgress: true
|
||||
});
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={api}>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import React, { createContext } from "react";
|
||||
import useSocket from "./useSocket"; // Import the custom hook
|
||||
|
||||
// Create the SocketContext
|
||||
const SocketContext = createContext(null);
|
||||
|
||||
export const SocketProvider = ({ children, bodyshop }) => {
|
||||
const { socket, clientId } = useSocket(bodyshop);
|
||||
|
||||
return <SocketContext.Provider value={{ socket, clientId }}> {children}</SocketContext.Provider>;
|
||||
};
|
||||
|
||||
export default SocketContext;
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import SocketIO from "socket.io-client";
|
||||
import { auth } from "../../firebase/firebase.utils";
|
||||
import { store } from "../../redux/store";
|
||||
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
|
||||
|
||||
const useSocket = (bodyshop) => {
|
||||
const socketRef = useRef(null);
|
||||
const [clientId, setClientId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeSocket = async (token) => {
|
||||
if (!bodyshop || !bodyshop.id) return;
|
||||
|
||||
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
|
||||
|
||||
const socketInstance = SocketIO(endpoint, {
|
||||
path: "/wss",
|
||||
withCredentials: true,
|
||||
auth: { token },
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 2000,
|
||||
reconnectionDelayMax: 10000
|
||||
});
|
||||
|
||||
socketRef.current = socketInstance;
|
||||
|
||||
// Handle socket events
|
||||
const handleBodyshopMessage = (message) => {
|
||||
if (!message || !message.type) return;
|
||||
|
||||
switch (message.type) {
|
||||
case "alert-update":
|
||||
store.dispatch(addAlerts(message.payload));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!import.meta.env.DEV) return;
|
||||
console.log(`Received message for bodyshop ${bodyshop.id}:`, message);
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
socketInstance.emit("join-bodyshop-room", bodyshop.id);
|
||||
setClientId(socketInstance.id);
|
||||
store.dispatch(setWssStatus("connected"));
|
||||
};
|
||||
|
||||
const handleReconnect = () => {
|
||||
store.dispatch(setWssStatus("connected"));
|
||||
};
|
||||
|
||||
const handleConnectionError = (err) => {
|
||||
console.error("Socket connection error:", err);
|
||||
|
||||
// Handle token expiration
|
||||
if (err.message.includes("auth/id-token-expired")) {
|
||||
console.warn("Token expired, refreshing...");
|
||||
auth.currentUser?.getIdToken(true).then((newToken) => {
|
||||
socketInstance.auth = { token: newToken }; // Update socket auth
|
||||
socketInstance.connect(); // Retry connection
|
||||
});
|
||||
} else {
|
||||
store.dispatch(setWssStatus("error"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = (reason) => {
|
||||
console.warn("Socket disconnected:", reason);
|
||||
store.dispatch(setWssStatus("disconnected"));
|
||||
|
||||
// Manually trigger reconnection if necessary
|
||||
if (!socketInstance.connected && reason !== "io server disconnect") {
|
||||
setTimeout(() => {
|
||||
if (socketInstance.disconnected) {
|
||||
console.log("Manually triggering reconnection...");
|
||||
socketInstance.connect();
|
||||
}
|
||||
}, 2000); // Retry after 2 seconds
|
||||
}
|
||||
};
|
||||
|
||||
// Register event handlers
|
||||
socketInstance.on("connect", handleConnect);
|
||||
socketInstance.on("reconnect", handleReconnect);
|
||||
socketInstance.on("connect_error", handleConnectionError);
|
||||
socketInstance.on("disconnect", handleDisconnect);
|
||||
socketInstance.on("bodyshop-message", handleBodyshopMessage);
|
||||
};
|
||||
|
||||
const unsubscribe = auth.onIdTokenChanged(async (user) => {
|
||||
if (user) {
|
||||
const token = await user.getIdToken();
|
||||
|
||||
if (socketRef.current) {
|
||||
// Update token if socket exists
|
||||
socketRef.current.emit("update-token", token);
|
||||
} else {
|
||||
// Initialize socket if not already connected
|
||||
initializeSocket(token);
|
||||
}
|
||||
} else {
|
||||
// User is not authenticated
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up on unmount
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [bodyshop]);
|
||||
|
||||
return { socket: socketRef.current, clientId };
|
||||
};
|
||||
|
||||
export default useSocket;
|
||||
492
client/src/contexts/SocketIO/useSocket.jsx
Normal file
492
client/src/contexts/SocketIO/useSocket.jsx
Normal file
@@ -0,0 +1,492 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import SocketIO from "socket.io-client";
|
||||
import { auth } from "../../firebase/firebase.utils";
|
||||
import { store } from "../../redux/store";
|
||||
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
|
||||
import client from "../../utils/GraphQLClient";
|
||||
import { useNotification } from "../Notifications/notificationContext.jsx";
|
||||
import {
|
||||
GET_NOTIFICATIONS,
|
||||
GET_UNREAD_COUNT,
|
||||
MARK_ALL_NOTIFICATIONS_READ,
|
||||
MARK_NOTIFICATION_READ,
|
||||
UPDATE_NOTIFICATIONS_READ_FRAGMENT
|
||||
} from "../../graphql/notifications.queries.js";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const SocketContext = createContext(null);
|
||||
|
||||
const INITIAL_NOTIFICATIONS = 10;
|
||||
|
||||
/**
|
||||
* Socket Provider - Scenario Notifications / Web Socket related items
|
||||
* @param children
|
||||
* @param bodyshop
|
||||
* @param navigate
|
||||
* @param currentUser
|
||||
* @param scenarioNotificationsOn
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const SocketProvider = ({ children, bodyshop, navigate, currentUser, scenarioNotificationsOn }) => {
|
||||
const socketRef = useRef(null);
|
||||
const [clientId, setClientId] = useState(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const notification = useNotification();
|
||||
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [markNotificationRead] = useMutation(MARK_NOTIFICATION_READ, {
|
||||
update: (cache, { data: { update_notifications } }) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const updatedNotification = update_notifications.returning[0];
|
||||
|
||||
cache.modify({
|
||||
fields: {
|
||||
notifications(existing = [], { readField }) {
|
||||
return existing.map((notif) =>
|
||||
readField("id", notif) === updatedNotification.id
|
||||
? {
|
||||
...notif,
|
||||
read: timestamp
|
||||
}
|
||||
: notif
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const unreadCountQuery = cache.readQuery({
|
||||
query: GET_UNREAD_COUNT,
|
||||
variables: { associationid: userAssociationId }
|
||||
});
|
||||
|
||||
if (unreadCountQuery?.notifications_aggregate?.aggregate?.count > 0) {
|
||||
cache.writeQuery({
|
||||
query: GET_UNREAD_COUNT,
|
||||
variables: { associationid: userAssociationId },
|
||||
data: {
|
||||
notifications_aggregate: {
|
||||
...unreadCountQuery.notifications_aggregate,
|
||||
aggregate: {
|
||||
...unreadCountQuery.notifications_aggregate.aggregate,
|
||||
count: unreadCountQuery.notifications_aggregate.aggregate.count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (socketRef.current && isConnected) {
|
||||
socketRef.current.emit("sync-notification-read", {
|
||||
email: currentUser?.email,
|
||||
bodyshopId: bodyshop.id,
|
||||
notificationId: updatedNotification.id
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err) =>
|
||||
console.error("MARK_NOTIFICATION_READ error:", {
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
})
|
||||
});
|
||||
|
||||
const [markAllNotificationsRead] = useMutation(MARK_ALL_NOTIFICATIONS_READ, {
|
||||
variables: { associationid: userAssociationId },
|
||||
update: (cache) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
cache.modify({
|
||||
fields: {
|
||||
notifications(existing = [], { readField }) {
|
||||
return existing.map((notif) =>
|
||||
readField("read", notif) === null && readField("associationid", notif) === userAssociationId
|
||||
? { ...notif, read: timestamp }
|
||||
: notif
|
||||
);
|
||||
},
|
||||
notifications_aggregate() {
|
||||
return { aggregate: { count: 0, __typename: "notifications_aggregate_fields" } };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const baseWhereClause = { associationid: { _eq: userAssociationId } };
|
||||
const cachedNotifications = cache.readQuery({
|
||||
query: GET_NOTIFICATIONS,
|
||||
variables: { limit: INITIAL_NOTIFICATIONS, offset: 0, where: baseWhereClause }
|
||||
});
|
||||
|
||||
if (cachedNotifications?.notifications) {
|
||||
cache.writeQuery({
|
||||
query: GET_NOTIFICATIONS,
|
||||
variables: { limit: INITIAL_NOTIFICATIONS, offset: 0, where: baseWhereClause },
|
||||
data: {
|
||||
notifications: cachedNotifications.notifications.map((notif) =>
|
||||
notif.read === null ? { ...notif, read: timestamp } : notif
|
||||
)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (socketRef.current && isConnected) {
|
||||
socketRef.current.emit("sync-all-notifications-read", {
|
||||
email: currentUser?.email,
|
||||
bodyshopId: bodyshop.id
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err) => console.error("MARK_ALL_NOTIFICATIONS_READ error:", err)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const initializeSocket = async (token) => {
|
||||
if (!bodyshop || !bodyshop.id || socketRef.current) return;
|
||||
|
||||
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
|
||||
const socketInstance = SocketIO(endpoint, {
|
||||
path: "/wss",
|
||||
withCredentials: true,
|
||||
auth: { token, bodyshopId: bodyshop.id },
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 2000,
|
||||
reconnectionDelayMax: 10000
|
||||
});
|
||||
|
||||
socketRef.current = socketInstance;
|
||||
|
||||
const handleBodyshopMessage = (message) => {
|
||||
if (!message || !message.type) return;
|
||||
switch (message.type) {
|
||||
case "alert-update":
|
||||
store.dispatch(addAlerts(message.payload));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
socketInstance.emit("join-bodyshop-room", bodyshop.id);
|
||||
setClientId(socketInstance.id);
|
||||
setIsConnected(true);
|
||||
store.dispatch(setWssStatus("connected"));
|
||||
};
|
||||
|
||||
const handleReconnect = () => {
|
||||
setIsConnected(true);
|
||||
store.dispatch(setWssStatus("connected"));
|
||||
};
|
||||
|
||||
const handleConnectionError = (err) => {
|
||||
console.error("Socket connection error:", err);
|
||||
setIsConnected(false);
|
||||
if (err.message.includes("auth/id-token-expired")) {
|
||||
console.warn("Token expired, refreshing...");
|
||||
auth.currentUser?.getIdToken(true).then((newToken) => {
|
||||
socketInstance.auth = { token: newToken };
|
||||
socketInstance.connect();
|
||||
});
|
||||
} else {
|
||||
store.dispatch(setWssStatus("error"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = (reason) => {
|
||||
console.warn("Socket disconnected:", reason);
|
||||
setIsConnected(false);
|
||||
store.dispatch(setWssStatus("disconnected"));
|
||||
if (!socketInstance.connected && reason !== "io server disconnect") {
|
||||
setTimeout(() => {
|
||||
if (socketInstance.disconnected) {
|
||||
console.log("Manually triggering reconnection...");
|
||||
socketInstance.connect();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotification = (data) => {
|
||||
// Scenario Notifications have been disabled, bail.
|
||||
if (!scenarioNotificationsOn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { jobId, jobRoNumber, notificationId, associationId, notifications } = data;
|
||||
if (associationId !== userAssociationId) return;
|
||||
|
||||
const newNotification = {
|
||||
__typename: "notifications",
|
||||
id: notificationId,
|
||||
jobid: jobId,
|
||||
associationid: associationId,
|
||||
scenario_text: JSON.stringify(notifications.map((notif) => notif.body)),
|
||||
fcm_text: notifications.map((notif) => notif.body).join(". ") + ".",
|
||||
scenario_meta: JSON.stringify(notifications.map((notif) => notif.variables || {})),
|
||||
created_at: new Date(notifications[0].timestamp).toISOString(),
|
||||
read: null,
|
||||
job: { ro_number: jobRoNumber }
|
||||
};
|
||||
|
||||
const baseVariables = {
|
||||
limit: INITIAL_NOTIFICATIONS,
|
||||
offset: 0,
|
||||
where: { associationid: { _eq: userAssociationId } }
|
||||
};
|
||||
|
||||
try {
|
||||
const existingNotifications =
|
||||
client.cache.readQuery({
|
||||
query: GET_NOTIFICATIONS,
|
||||
variables: baseVariables
|
||||
})?.notifications || [];
|
||||
if (!existingNotifications.some((n) => n.id === newNotification.id)) {
|
||||
client.cache.writeQuery({
|
||||
query: GET_NOTIFICATIONS,
|
||||
variables: baseVariables,
|
||||
data: {
|
||||
notifications: [newNotification, ...existingNotifications].sort(
|
||||
(a, b) => new Date(b.created_at) - new Date(a.created_at)
|
||||
)
|
||||
},
|
||||
broadcast: true
|
||||
});
|
||||
|
||||
const unreadVariables = {
|
||||
...baseVariables,
|
||||
where: { ...baseVariables.where, read: { _is_null: true } }
|
||||
};
|
||||
const unreadNotifications =
|
||||
client.cache.readQuery({
|
||||
query: GET_NOTIFICATIONS,
|
||||
variables: unreadVariables
|
||||
})?.notifications || [];
|
||||
if (newNotification.read === null && !unreadNotifications.some((n) => n.id === newNotification.id)) {
|
||||
client.cache.writeQuery({
|
||||
query: GET_NOTIFICATIONS,
|
||||
variables: unreadVariables,
|
||||
data: {
|
||||
notifications: [newNotification, ...unreadNotifications].sort(
|
||||
(a, b) => new Date(b.created_at) - new Date(a.created_at)
|
||||
)
|
||||
},
|
||||
broadcast: true
|
||||
});
|
||||
}
|
||||
|
||||
client.cache.modify({
|
||||
id: "ROOT_QUERY",
|
||||
fields: {
|
||||
notifications_aggregate(existing = { aggregate: { count: 0 } }) {
|
||||
return {
|
||||
...existing,
|
||||
aggregate: {
|
||||
...existing.aggregate,
|
||||
count: existing.aggregate.count + (newNotification.read === null ? 1 : 0)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
notification.info({
|
||||
message: (
|
||||
<div
|
||||
onClick={() => {
|
||||
markNotificationRead({ variables: { id: notificationId } })
|
||||
.then(() => navigate(`/manage/jobs/${jobId}`))
|
||||
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`));
|
||||
}}
|
||||
>
|
||||
{t("notifications.labels.notification-popup-title", {
|
||||
ro_number: jobRoNumber || t("general.labels.na")
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
description: (
|
||||
<ul
|
||||
className="notification-alert-unordered-list"
|
||||
onClick={() => {
|
||||
markNotificationRead({ variables: { id: notificationId } })
|
||||
.then(() => navigate(`/manage/jobs/${jobId}`))
|
||||
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`));
|
||||
}}
|
||||
>
|
||||
{notifications.map((notif, index) => (
|
||||
<li className="notification-alert-unordered-list-item" key={index}>
|
||||
{notif.body}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error handling new notification: ${error?.message || ""}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncNotificationRead = ({ notificationId, timestamp }) => {
|
||||
// Scenario Notifications have been disabled, bail.
|
||||
if (!scenarioNotificationsOn) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const notificationRef = client.cache.identify({
|
||||
__typename: "notifications",
|
||||
id: notificationId
|
||||
});
|
||||
client.cache.writeFragment({
|
||||
id: notificationRef,
|
||||
fragment: UPDATE_NOTIFICATIONS_READ_FRAGMENT,
|
||||
data: { read: timestamp }
|
||||
});
|
||||
|
||||
const unreadCountData = client.cache.readQuery({
|
||||
query: GET_UNREAD_COUNT,
|
||||
variables: { associationid: userAssociationId }
|
||||
});
|
||||
if (unreadCountData?.notifications_aggregate?.aggregate?.count > 0) {
|
||||
const newCount = Math.max(unreadCountData.notifications_aggregate.aggregate.count - 1, 0);
|
||||
client.cache.writeQuery({
|
||||
query: GET_UNREAD_COUNT,
|
||||
variables: { associationid: userAssociationId },
|
||||
data: {
|
||||
notifications_aggregate: {
|
||||
__typename: "notifications_aggregate",
|
||||
aggregate: {
|
||||
__typename: "notifications_aggregate_fields",
|
||||
count: newCount
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in handleSyncNotificationRead:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncAllNotificationsRead = ({ timestamp }) => {
|
||||
// Scenario Notifications have been disabled, bail.
|
||||
if (!scenarioNotificationsOn) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const queryVars = {
|
||||
limit: INITIAL_NOTIFICATIONS,
|
||||
offset: 0,
|
||||
where: { associationid: { _eq: userAssociationId } }
|
||||
};
|
||||
const cachedData = client.cache.readQuery({
|
||||
query: GET_NOTIFICATIONS,
|
||||
variables: queryVars
|
||||
});
|
||||
|
||||
if (cachedData?.notifications) {
|
||||
cachedData.notifications.forEach((notif) => {
|
||||
if (!notif.read) {
|
||||
const notifRef = client.cache.identify({ __typename: "notifications", id: notif.id });
|
||||
client.cache.writeFragment({
|
||||
id: notifRef,
|
||||
fragment: UPDATE_NOTIFICATIONS_READ_FRAGMENT,
|
||||
data: { read: timestamp }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
client.cache.writeQuery({
|
||||
query: GET_UNREAD_COUNT,
|
||||
variables: { associationid: userAssociationId },
|
||||
data: {
|
||||
notifications_aggregate: {
|
||||
__typename: "notifications_aggregate",
|
||||
aggregate: {
|
||||
__typename: "notifications_aggregate_fields",
|
||||
count: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error In HandleSyncAllNotificationsRead: ${error?.message || ""}`);
|
||||
}
|
||||
};
|
||||
|
||||
socketInstance.on("connect", handleConnect);
|
||||
socketInstance.on("reconnect", handleReconnect);
|
||||
socketInstance.on("connect_error", handleConnectionError);
|
||||
socketInstance.on("disconnect", handleDisconnect);
|
||||
socketInstance.on("bodyshop-message", handleBodyshopMessage);
|
||||
socketInstance.on("notification", handleNotification);
|
||||
socketInstance.on("sync-notification-read", handleSyncNotificationRead);
|
||||
socketInstance.on("sync-all-notifications-read", handleSyncAllNotificationsRead);
|
||||
};
|
||||
|
||||
const unsubscribe = auth.onIdTokenChanged(async (user) => {
|
||||
if (user) {
|
||||
const token = await user.getIdToken();
|
||||
if (socketRef.current) {
|
||||
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
|
||||
} else {
|
||||
initializeSocket(token).catch((err) =>
|
||||
console.error(`Something went wrong Initializing Sockets: ${err?.message || ""}`)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
setIsConnected(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
bodyshop,
|
||||
notification,
|
||||
userAssociationId,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
navigate,
|
||||
currentUser,
|
||||
scenarioNotificationsOn,
|
||||
t
|
||||
]);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider
|
||||
value={{
|
||||
socket: socketRef.current,
|
||||
clientId,
|
||||
isConnected,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
scenarioNotificationsOn
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useSocket = () => {
|
||||
const context = useContext(SocketContext);
|
||||
// NOTE: Not sure if we absolutely require this, does cause slipups on dev env
|
||||
if (!context) throw new Error("useSocket must be used within a SocketProvider");
|
||||
return context;
|
||||
};
|
||||
|
||||
export { SocketContext, SocketProvider, INITIAL_NOTIFICATIONS, useSocket };
|
||||
@@ -349,3 +349,13 @@ export const QUERY_STRIPE_ID = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_ACTIVE_EMPLOYEES_IN_SHOP = gql`
|
||||
query GetActiveEmployeesInShop($shopid: uuid!) {
|
||||
associations(where: { shopid: { _eq: $shopid } }) {
|
||||
id
|
||||
useremail
|
||||
shopid
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -524,6 +524,10 @@ export const GET_JOB_BY_PK = gql`
|
||||
invoice_final_note
|
||||
iouparent
|
||||
job_totals
|
||||
job_watchers {
|
||||
id
|
||||
user_email
|
||||
}
|
||||
joblines(where: { removed: { _eq: false } }, order_by: { line_no: asc }) {
|
||||
act_price
|
||||
act_price_before_ppc
|
||||
@@ -2567,3 +2571,34 @@ export const GET_JOB_BY_PK_QUICK_INTAKE = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_JOB_WATCHERS = gql`
|
||||
query GET_JOB_WATCHERS($jobid: uuid!) {
|
||||
job_watchers(where: { jobid: { _eq: $jobid } }) {
|
||||
id
|
||||
user_email
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ADD_JOB_WATCHER = gql`
|
||||
mutation ADD_JOB_WATCHER($jobid: uuid!, $userEmail: String!) {
|
||||
insert_job_watchers_one(object: { jobid: $jobid, user_email: $userEmail }) {
|
||||
id
|
||||
jobid
|
||||
user_email
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REMOVE_JOB_WATCHER = gql`
|
||||
mutation REMOVE_JOB_WATCHER($jobid: uuid!, $userEmail: String!) {
|
||||
delete_job_watchers(where: { jobid: { _eq: $jobid }, user_email: { _eq: $userEmail } }) {
|
||||
affected_rows
|
||||
returning {
|
||||
id
|
||||
user_email
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
58
client/src/graphql/notifications.queries.js
Normal file
58
client/src/graphql/notifications.queries.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { gql } from "@apollo/client";
|
||||
|
||||
export const GET_NOTIFICATIONS = gql`
|
||||
query GetNotifications($limit: Int!, $offset: Int!, $where: notifications_bool_exp) {
|
||||
notifications(limit: $limit, offset: $offset, order_by: { created_at: desc }, where: $where) {
|
||||
id
|
||||
jobid
|
||||
associationid
|
||||
scenario_text
|
||||
fcm_text
|
||||
scenario_meta
|
||||
created_at
|
||||
read
|
||||
job {
|
||||
id
|
||||
ro_number
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_UNREAD_COUNT = gql`
|
||||
query GetUnreadCount($associationid: uuid!) {
|
||||
notifications_aggregate(where: { read: { _is_null: true }, associationid: { _eq: $associationid } }) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MARK_ALL_NOTIFICATIONS_READ = gql`
|
||||
mutation MarkAllNotificationsRead($associationid: uuid!) {
|
||||
update_notifications(
|
||||
where: { read: { _is_null: true }, associationid: { _eq: $associationid } }
|
||||
_set: { read: "now()" }
|
||||
) {
|
||||
affected_rows
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MARK_NOTIFICATION_READ = gql`
|
||||
mutation MarkNotificationRead($id: uuid!) {
|
||||
update_notifications(where: { id: { _eq: $id } }, _set: { read: "now()" }) {
|
||||
returning {
|
||||
id
|
||||
read
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_NOTIFICATIONS_READ_FRAGMENT = gql`
|
||||
fragment UpdateNotificationRead on notifications {
|
||||
read
|
||||
}
|
||||
`;
|
||||
@@ -85,3 +85,21 @@ export const UPDATE_KANBAN_SETTINGS = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const QUERY_NOTIFICATION_SETTINGS = gql`
|
||||
query QUERY_NOTIFICATION_SETTINGS($email: String!) {
|
||||
associations(where: { _and: { useremail: { _eq: $email }, active: { _eq: true } } }) {
|
||||
id
|
||||
notification_settings
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_NOTIFICATION_SETTINGS = gql`
|
||||
mutation UPDATE_NOTIFICATION_SETTINGS($id: uuid!, $ns: jsonb) {
|
||||
update_associations_by_pk(pk_columns: { id: $id }, _set: { notification_settings: $ns }) {
|
||||
id
|
||||
notification_settings
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -56,6 +56,8 @@ import { DateTimeFormat } from "../../utils/DateFormatter";
|
||||
import dayjs from "../../utils/day";
|
||||
import UndefinedToNull from "../../utils/undefinedtonull";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import JobWatcherToggleContainer from "../../components/job-watcher-toggle/job-watcher-toggle.container.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -102,6 +104,7 @@ export function JobsDetailPage({
|
||||
nextFetchPolicy: "network-only"
|
||||
});
|
||||
const notification = useNotification();
|
||||
const { scenarioNotificationsOn } = useSocket();
|
||||
|
||||
useEffect(() => {
|
||||
//form.setFieldsValue(transormJobToForm(job));
|
||||
@@ -319,7 +322,13 @@ export function JobsDetailPage({
|
||||
>
|
||||
<PageHeader
|
||||
// onBack={() => window.history.back()}
|
||||
title={job.ro_number || t("general.labels.na")}
|
||||
|
||||
title={
|
||||
<Space>
|
||||
{scenarioNotificationsOn && <JobWatcherToggleContainer job={job} />}
|
||||
{job.ro_number || t("general.labels.na")}
|
||||
</Space>
|
||||
}
|
||||
extra={menuExtra}
|
||||
/>
|
||||
<JobsDetailHeader job={job} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FloatButton, Layout, Spin } from "antd";
|
||||
|
||||
// import preval from "preval.macro";
|
||||
import React, { lazy, Suspense, useContext, useEffect, useState } from "react";
|
||||
import React, { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { Link, Route, Routes } from "react-router-dom";
|
||||
@@ -20,7 +20,7 @@ import PartnerPingComponent from "../../components/partner-ping/partner-ping.com
|
||||
import PrintCenterModalContainer from "../../components/print-center-modal/print-center-modal.container";
|
||||
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
|
||||
import { requestForToken } from "../../firebase/firebase.utils";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors";
|
||||
import UpdateAlert from "../../components/update-alert/update-alert.component";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
|
||||
@@ -29,6 +29,7 @@ import WssStatusDisplayComponent from "../../components/wss-status-display/wss-s
|
||||
import { selectAlerts } from "../../redux/application/application.selectors.js";
|
||||
import { addAlerts } from "../../redux/application/application.actions.js";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
const JobsPage = lazy(() => import("../jobs/jobs.page"));
|
||||
|
||||
const CardPaymentModalContainer = lazy(
|
||||
@@ -122,7 +123,7 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
|
||||
const { t } = useTranslation();
|
||||
const [chatVisible] = useState(false);
|
||||
const { socket, clientId } = useContext(SocketContext);
|
||||
const { socket, clientId } = useSocket();
|
||||
const notification = useNotification();
|
||||
|
||||
// State to track displayed alerts
|
||||
@@ -146,7 +147,7 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
|
||||
}
|
||||
};
|
||||
|
||||
fetchAlerts();
|
||||
fetchAlerts().catch((err) => `Error fetching Bodyshop Alerts: ${err?.message || ""}`);
|
||||
}, [setAlerts]);
|
||||
|
||||
// Use useEffect to watch for new alerts
|
||||
@@ -166,7 +167,6 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
|
||||
description: alert.description,
|
||||
type: alert.type || "info",
|
||||
duration: 0,
|
||||
placement: "bottomRight",
|
||||
closable: true,
|
||||
onClose: () => {
|
||||
// When the notification is closed, update displayed alerts state and localStorage
|
||||
|
||||
@@ -3766,6 +3766,60 @@
|
||||
"validation": {
|
||||
"unique_vendor_name": "You must enter a unique vendor name."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"labels": {
|
||||
"notification-center": "Notification Center",
|
||||
"scenario": "Scenario",
|
||||
"notificationscenarios": "Job Notification Scenarios",
|
||||
"save": "Save Scenarios",
|
||||
"watching-issue": "Watching",
|
||||
"add-watchers": "Add Watchers",
|
||||
"employee-search": "Search for an Employee",
|
||||
"teams-search": "Search for a Team",
|
||||
"add-watchers-team": "Add Team Members",
|
||||
"new-notification-title": "New Notification:",
|
||||
"show-unread-only": "Show Unread Only",
|
||||
"mark-all-read": "Mark All Read",
|
||||
"notification-popup-title": "Changes for Job #{{ro_number}}",
|
||||
"ro-number": "RO #{{ro_number}}",
|
||||
"no-watchers": "No Watchers",
|
||||
"notification-settings-success": "Notification Settings saved successfully.",
|
||||
"notification-settings-failure": "Error saving Notification Settings. {{error}}",
|
||||
"watch": "Watch",
|
||||
"unwatch": "Unwatch"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Remove"
|
||||
},
|
||||
"aria": {
|
||||
"toggle": "Toggle Watching Job"
|
||||
},
|
||||
"tooltips": {
|
||||
"job-watchers": "Job Watchers"
|
||||
},
|
||||
"scenarios": {
|
||||
"job-assigned-to-me": "Job Assigned to Me",
|
||||
"bill-posted": "Bill Posted",
|
||||
"critical-parts-status-changed": "Critical Parts Status Changed",
|
||||
"part-marked-back-ordered": "Part Marked Back Ordered",
|
||||
"new-note-added": "New Note Added",
|
||||
"supplement-imported": "Supplement Imported",
|
||||
"schedule-dates-changed": "Schedule Dates Changed",
|
||||
"tasks-updated-created": "Tasks Updated / Created",
|
||||
"new-media-added-reassigned": "New Media Added or Reassigned",
|
||||
"new-time-ticket-posted": "New Time Ticket Posted",
|
||||
"intake-delivery-checklist-completed": "Intake or Delivery Checklist Completed",
|
||||
"job-added-to-production": "Job Added to Production",
|
||||
"job-status-change": "Job Status Changed",
|
||||
"payment-collected-completed": "Payment Collected / Completed",
|
||||
"alternate-transport-changed": "Alternate Transport Changed"
|
||||
},
|
||||
"channels": {
|
||||
"app": "App",
|
||||
"email": "Email",
|
||||
"fcm": "Push"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3766,6 +3766,60 @@
|
||||
"validation": {
|
||||
"unique_vendor_name": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"labels": {
|
||||
"notification-center": "",
|
||||
"scenario": "",
|
||||
"notificationscenarios": "",
|
||||
"save": "",
|
||||
"watching-issue": "",
|
||||
"add-watchers": "",
|
||||
"employee-search": "",
|
||||
"teams-search": "",
|
||||
"add-watchers-team": "",
|
||||
"new-notification-title": "",
|
||||
"show-unread-only": "",
|
||||
"mark-all-read": "",
|
||||
"notification-popup-title": "",
|
||||
"ro-number": "",
|
||||
"no-watchers": "",
|
||||
"notification-settings-success": "",
|
||||
"notification-settings-failure": "",
|
||||
"watch": "",
|
||||
"unwatch": ""
|
||||
},
|
||||
"actions": {
|
||||
"remove": ""
|
||||
},
|
||||
"aria": {
|
||||
"toggle": ""
|
||||
},
|
||||
"tooltips": {
|
||||
"job-watchers": ""
|
||||
},
|
||||
"scenarios": {
|
||||
"job-assigned-to-me": "",
|
||||
"bill-posted": "",
|
||||
"critical-parts-status-changed": "",
|
||||
"part-marked-back-ordered": "",
|
||||
"new-note-added": "",
|
||||
"supplement-imported": "",
|
||||
"schedule-dates-changed": "",
|
||||
"tasks-updated-created": "",
|
||||
"new-media-added-reassigned": "",
|
||||
"new-time-ticket-posted": "",
|
||||
"intake-delivery-checklist-completed": "",
|
||||
"job-added-to-production": "",
|
||||
"job-status-change": "",
|
||||
"payment-collected-completed": "",
|
||||
"alternate-transport-changed": ""
|
||||
},
|
||||
"channels": {
|
||||
"app": "",
|
||||
"email": "",
|
||||
"fcm": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3766,6 +3766,60 @@
|
||||
"validation": {
|
||||
"unique_vendor_name": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"labels": {
|
||||
"notification-center": "",
|
||||
"scenario": "",
|
||||
"notificationscenarios": "",
|
||||
"save": "",
|
||||
"watching-issue": "",
|
||||
"add-watchers": "",
|
||||
"employee-search": "",
|
||||
"teams-search": "",
|
||||
"add-watchers-team": "",
|
||||
"new-notification-title": "",
|
||||
"show-unread-only": "",
|
||||
"mark-all-read": "",
|
||||
"notification-popup-title": "",
|
||||
"ro-number": "",
|
||||
"no-watchers": "",
|
||||
"notification-settings-success": "",
|
||||
"notification-settings-failure": "",
|
||||
"watch": "",
|
||||
"unwatch": ""
|
||||
},
|
||||
"actions": {
|
||||
"remove": ""
|
||||
},
|
||||
"aria": {
|
||||
"toggle": ""
|
||||
},
|
||||
"tooltips": {
|
||||
"job-watchers": ""
|
||||
},
|
||||
"scenarios": {
|
||||
"job-assigned-to-me": "",
|
||||
"bill-posted": "",
|
||||
"critical-parts-status-changed": "",
|
||||
"part-marked-back-ordered": "",
|
||||
"new-note-added": "",
|
||||
"supplement-imported": "",
|
||||
"schedule-dates-changed": "",
|
||||
"tasks-updated-created": "",
|
||||
"new-media-added-reassigned": "",
|
||||
"new-time-ticket-posted": "",
|
||||
"intake-delivery-checklist-completed": "",
|
||||
"job-added-to-production": "",
|
||||
"job-status-change": "",
|
||||
"payment-collected-completed": "",
|
||||
"alternate-transport-changed": ""
|
||||
},
|
||||
"channels": {
|
||||
"app": "",
|
||||
"email": "",
|
||||
"fcm": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,41 @@ middlewares.push(
|
||||
new SentryLink().concat(roundTripLink.concat(retryLink.concat(errorLink.concat(authLink.concat(link)))))
|
||||
);
|
||||
|
||||
const cache = new InMemoryCache({});
|
||||
const cache = new InMemoryCache({
|
||||
typePolicies: {
|
||||
Query: {
|
||||
fields: {
|
||||
// Note: This is required because we switch from a read to an unread state with a toggle,
|
||||
notifications: {
|
||||
merge(existing = [], incoming = [], { readField }) {
|
||||
// Create a map to deduplicate by __ref
|
||||
const merged = new Map();
|
||||
|
||||
// Add existing items to retain cached data
|
||||
existing.forEach((item) => {
|
||||
const ref = readField("__ref", item);
|
||||
if (ref) {
|
||||
merged.set(ref, item);
|
||||
}
|
||||
});
|
||||
|
||||
// Add incoming items, overwriting duplicates
|
||||
incoming.forEach((item) => {
|
||||
const ref = readField("__ref", item);
|
||||
if (ref) {
|
||||
merged.set(ref, item);
|
||||
}
|
||||
});
|
||||
|
||||
// Return incoming to respect the current query’s filter (e.g., unread-only or all)
|
||||
return incoming;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const client = new ApolloClient({
|
||||
link: ApolloLink.from(middlewares),
|
||||
cache,
|
||||
@@ -163,4 +197,5 @@ const client = new ApolloClient({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default client;
|
||||
|
||||
19
client/src/utils/jobNotificationScenarios.js
Normal file
19
client/src/utils/jobNotificationScenarios.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const notificationScenarios = [
|
||||
"job-assigned-to-me",
|
||||
"bill-posted",
|
||||
"critical-parts-status-changed",
|
||||
"part-marked-back-ordered",
|
||||
"new-note-added",
|
||||
"supplement-imported",
|
||||
"schedule-dates-changed",
|
||||
"tasks-updated-created",
|
||||
"new-media-added-reassigned",
|
||||
"new-time-ticket-posted",
|
||||
"intake-delivery-checklist-completed",
|
||||
"job-added-to-production",
|
||||
"job-status-change",
|
||||
"payment-collected-completed",
|
||||
"alternate-transport-changed"
|
||||
];
|
||||
|
||||
export { notificationScenarios };
|
||||
221
docker-compose-cluster.yml
Normal file
221
docker-compose-cluster.yml
Normal file
@@ -0,0 +1,221 @@
|
||||
services:
|
||||
# Load Balancer (NGINX) with WebSocket support and session persistence
|
||||
load-balancer:
|
||||
image: nginx:latest
|
||||
container_name: load-balancer
|
||||
ports:
|
||||
- "4000:80" # External port 4000 maps to NGINX's port 80
|
||||
volumes:
|
||||
- ./nginx-websocket.conf:/etc/nginx/nginx.conf:ro # Mount NGINX configuration
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
depends_on:
|
||||
- node-app-1
|
||||
- node-app-2
|
||||
- node-app-3
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://localhost/health" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Node App Instance 1
|
||||
node-app-1:
|
||||
build:
|
||||
context: .
|
||||
container_name: node-app-1
|
||||
hostname: node-app-1
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
env_file:
|
||||
- .env.development
|
||||
depends_on:
|
||||
redis-node-1:
|
||||
condition: service_healthy
|
||||
redis-node-2:
|
||||
condition: service_healthy
|
||||
redis-node-3:
|
||||
condition: service_healthy
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
aws-cli:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "4001:4000" # Different external port for local access
|
||||
volumes:
|
||||
- .:/app
|
||||
- node-app-npm-cache:/app/node_modules
|
||||
|
||||
# Node App Instance 2
|
||||
node-app-2:
|
||||
build:
|
||||
context: .
|
||||
container_name: node-app-2
|
||||
hostname: node-app-2
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
env_file:
|
||||
- .env.development
|
||||
depends_on:
|
||||
redis-node-1:
|
||||
condition: service_healthy
|
||||
redis-node-2:
|
||||
condition: service_healthy
|
||||
redis-node-3:
|
||||
condition: service_healthy
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
aws-cli:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "4002:4000" # Different external port for local access
|
||||
volumes:
|
||||
- .:/app
|
||||
- node-app-npm-cache:/app/node_modules
|
||||
|
||||
# Node App Instance 3
|
||||
node-app-3:
|
||||
build:
|
||||
context: .
|
||||
container_name: node-app-3
|
||||
hostname: node-app-3
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
env_file:
|
||||
- .env.development
|
||||
depends_on:
|
||||
redis-node-1:
|
||||
condition: service_healthy
|
||||
redis-node-2:
|
||||
condition: service_healthy
|
||||
redis-node-3:
|
||||
condition: service_healthy
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
aws-cli:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "4003:4000" # Different external port for local access
|
||||
volumes:
|
||||
- .:/app
|
||||
- node-app-npm-cache:/app/node_modules
|
||||
|
||||
# Redis Node 1
|
||||
redis-node-1:
|
||||
build:
|
||||
context: ./redis
|
||||
container_name: redis-node-1
|
||||
hostname: redis-node-1
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
volumes:
|
||||
- redis-node-1-data:/data
|
||||
- redis-lock:/redis-lock
|
||||
healthcheck:
|
||||
test: [ "CMD", "redis-cli", "ping" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# Redis Node 2
|
||||
redis-node-2:
|
||||
build:
|
||||
context: ./redis
|
||||
container_name: redis-node-2
|
||||
hostname: redis-node-2
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
volumes:
|
||||
- redis-node-2-data:/data
|
||||
- redis-lock:/redis-lock
|
||||
healthcheck:
|
||||
test: [ "CMD", "redis-cli", "ping" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# Redis Node 3
|
||||
redis-node-3:
|
||||
build:
|
||||
context: ./redis
|
||||
container_name: redis-node-3
|
||||
hostname: redis-node-3
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
volumes:
|
||||
- redis-node-3-data:/data
|
||||
- redis-lock:/redis-lock
|
||||
healthcheck:
|
||||
test: [ "CMD", "redis-cli", "ping" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# LocalStack
|
||||
localstack:
|
||||
image: localstack/localstack
|
||||
container_name: localstack
|
||||
hostname: localstack
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- SERVICES=s3,ses,secretsmanager,cloudwatch,logs
|
||||
- DEBUG=0
|
||||
- AWS_ACCESS_KEY_ID=test
|
||||
- AWS_SECRET_ACCESS_KEY=test
|
||||
- AWS_DEFAULT_REGION=ca-central-1
|
||||
- EXTRA_CORS_ALLOWED_HEADERS=Authorization,Content-Type
|
||||
- EXTRA_CORS_ALLOWED_ORIGINS=*
|
||||
- EXTRA_CORS_EXPOSE_HEADERS=Authorization,Content-Type
|
||||
ports:
|
||||
- "4566:4566"
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://localhost:4566/_localstack/health" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
# AWS-CLI
|
||||
aws-cli:
|
||||
image: amazon/aws-cli
|
||||
container_name: aws-cli
|
||||
hostname: aws-cli
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
depends_on:
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- './localstack:/tmp/localstack'
|
||||
- './certs:/tmp/certs'
|
||||
environment:
|
||||
- AWS_ACCESS_KEY_ID=test
|
||||
- AWS_SECRET_ACCESS_KEY=test
|
||||
- AWS_DEFAULT_REGION=ca-central-1
|
||||
entrypoint: /bin/sh -c
|
||||
command: >
|
||||
"
|
||||
aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
|
||||
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
"
|
||||
|
||||
networks:
|
||||
redis-cluster-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
node-app-npm-cache:
|
||||
redis-node-1-data:
|
||||
redis-node-2-data:
|
||||
redis-node-3-data:
|
||||
redis-lock:
|
||||
@@ -31,14 +31,6 @@
|
||||
headers:
|
||||
- name: x-imex-auth
|
||||
value_from_env: DATAPUMP_AUTH
|
||||
- name: Task Reminders
|
||||
webhook: '{{HASURA_API_URL}}/tasks-remind-handler'
|
||||
schedule: '*/15 * * * *'
|
||||
include_in_metadata: true
|
||||
payload: {}
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
- name: Rome Usage Report
|
||||
webhook: '{{HASURA_API_URL}}/data/usagereport'
|
||||
schedule: 0 12 * * 5
|
||||
@@ -47,3 +39,11 @@
|
||||
headers:
|
||||
- name: x-imex-auth
|
||||
value_from_env: DATAPUMP_AUTH
|
||||
- name: Task Reminders
|
||||
webhook: '{{HASURA_API_URL}}/tasks-remind-handler'
|
||||
schedule: '*/15 * * * *'
|
||||
include_in_metadata: true
|
||||
payload: {}
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
|
||||
@@ -198,6 +198,14 @@
|
||||
- name: user
|
||||
using:
|
||||
foreign_key_constraint_on: useremail
|
||||
array_relationships:
|
||||
- name: notifications
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: associationid
|
||||
table:
|
||||
name: notifications
|
||||
schema: public
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
@@ -697,12 +705,6 @@
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
body:
|
||||
action: transform
|
||||
template: |-
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
@@ -1133,6 +1135,46 @@
|
||||
- active:
|
||||
_eq: true
|
||||
check: null
|
||||
event_triggers:
|
||||
- name: cache_bodyshop
|
||||
definition:
|
||||
enable_manual: false
|
||||
update:
|
||||
columns:
|
||||
- shopname
|
||||
- md_order_statuses
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
timeout_sec: 60
|
||||
webhook_from_env: HASURA_API_URL
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
body:
|
||||
action: transform
|
||||
template: |-
|
||||
{
|
||||
"created_at": {{$body.created_at}},
|
||||
"delivery_info": {{$body.delivery_info}},
|
||||
"event": {
|
||||
"data": {
|
||||
"new": {
|
||||
"id": {{$body.event.data.new.id}},
|
||||
"shopname": {{$body.event.data.new.shopname}},
|
||||
"md_order_statuses": {{$body.event.data.new.md_order_statuses}}
|
||||
}
|
||||
},
|
||||
"op": {{$body.event.op}},
|
||||
"session_variables": {{$body.event.session_variables}}
|
||||
}
|
||||
}
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/bodyshop-cache'
|
||||
version: 2
|
||||
- table:
|
||||
name: cccontracts
|
||||
schema: public
|
||||
@@ -1958,6 +2000,29 @@
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: notifications_documents
|
||||
definition:
|
||||
enable_manual: false
|
||||
insert:
|
||||
columns: '*'
|
||||
update:
|
||||
columns:
|
||||
- jobid
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
timeout_sec: 60
|
||||
webhook_from_env: HASURA_API_URL
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/notifications/events/handleDocumentsChange'
|
||||
version: 2
|
||||
- table:
|
||||
name: email_audit_trail
|
||||
schema: public
|
||||
@@ -2846,13 +2911,12 @@
|
||||
- role: user
|
||||
permission:
|
||||
check:
|
||||
user:
|
||||
_and:
|
||||
- associations:
|
||||
active:
|
||||
_eq: true
|
||||
- authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
job:
|
||||
bodyshop:
|
||||
associations:
|
||||
user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
columns:
|
||||
- user_email
|
||||
- created_at
|
||||
@@ -2868,13 +2932,12 @@
|
||||
- id
|
||||
- jobid
|
||||
filter:
|
||||
user:
|
||||
_and:
|
||||
- associations:
|
||||
active:
|
||||
_eq: true
|
||||
- authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
job:
|
||||
bodyshop:
|
||||
associations:
|
||||
user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
comment: ""
|
||||
update_permissions:
|
||||
- role: user
|
||||
@@ -2885,26 +2948,24 @@
|
||||
- id
|
||||
- jobid
|
||||
filter:
|
||||
user:
|
||||
_and:
|
||||
- associations:
|
||||
active:
|
||||
_eq: true
|
||||
- authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
job:
|
||||
bodyshop:
|
||||
associations:
|
||||
user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
check: null
|
||||
comment: ""
|
||||
delete_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
filter:
|
||||
user:
|
||||
_and:
|
||||
- associations:
|
||||
active:
|
||||
_eq: true
|
||||
- authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
job:
|
||||
bodyshop:
|
||||
associations:
|
||||
user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
comment: ""
|
||||
- table:
|
||||
name: joblines
|
||||
@@ -3223,6 +3284,31 @@
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: notifications_joblines
|
||||
definition:
|
||||
enable_manual: false
|
||||
update:
|
||||
columns:
|
||||
- critical
|
||||
- status
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
timeout_sec: 60
|
||||
webhook_from_env: HASURA_API_URL
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
body:
|
||||
action: transform
|
||||
template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": \"UPDATE\",\r\n \"data\": {\r\n \"old\": {\r\n \"id\": {{$body.event.data.old.id}},\r\n \"jobid\": {{$body.event.data.old.jobid}},\r\n \"critical\": {{$body.event.data.old.critical}},\r\n \"status\": {{$body.event.data.old.status}},\r\n \"line_desc\": {{$body.event.data.old.line_desc}}\r\n },\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"jobid\": {{$body.event.data.new.jobid}},\r\n \"critical\": {{$body.event.data.new.critical}},\r\n \"status\": {{$body.event.data.new.status}},\r\n \"line_desc\": {{$body.event.data.new.line_desc}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_joblines\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"joblines\"\r\n }\r\n}\r\n"
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/notifications/events/handleJobLinesChange'
|
||||
version: 2
|
||||
- table:
|
||||
name: joblines_status
|
||||
schema: public
|
||||
@@ -3369,6 +3455,13 @@
|
||||
table:
|
||||
name: job_conversations
|
||||
schema: public
|
||||
- name: job_watchers
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: jobid
|
||||
table:
|
||||
name: job_watchers
|
||||
schema: public
|
||||
- name: joblines
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
@@ -3399,6 +3492,13 @@
|
||||
table:
|
||||
name: notes
|
||||
schema: public
|
||||
- name: notifications
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: jobid
|
||||
table:
|
||||
name: notifications
|
||||
schema: public
|
||||
- name: parts_dispatches
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
@@ -4473,10 +4573,7 @@
|
||||
request_transform:
|
||||
body:
|
||||
action: transform
|
||||
template: |-
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": \"UPDATE\",\r\n \"data\": {\r\n \"old\": {\r\n \"id\": {{$body.event.data.old.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.old.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.old.employee_prep}},\r\n \"clm_total\": {{$body.event.data.old.clm_total}},\r\n \"towin\": {{$body.event.data.old.towin}},\r\n \"employee_body\": {{$body.event.data.old.employee_body}},\r\n \"converted\": {{$body.event.data.old.converted}},\r\n \"scheduled_in\": {{$body.event.data.old.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.old.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.old.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.old.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.old.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.old.alt_transport}},\r\n \"date_exported\": {{$body.event.data.old.date_exported}},\r\n \"status\": {{$body.event.data.old.status}},\r\n \"employee_csr\": {{$body.event.data.old.employee_csr}},\r\n \"actual_in\": {{$body.event.data.old.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.old.deliverchecklist}},\r\n \"comment\": {{$body.event.data.old.comment}},\r\n \"employee_refinish\": {{$body.event.data.old.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.old.inproduction}},\r\n \"production_vars\": {{$body.event.data.old.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.old.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.old.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.old.date_invoiced}}\r\n },\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.new.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.new.employee_prep}},\r\n \"clm_total\": {{$body.event.data.new.clm_total}},\r\n \"towin\": {{$body.event.data.new.towin}},\r\n \"employee_body\": {{$body.event.data.new.employee_body}},\r\n \"converted\": {{$body.event.data.new.converted}},\r\n \"scheduled_in\": {{$body.event.data.new.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.new.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.new.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.new.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.new.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.new.alt_transport}},\r\n \"date_exported\": {{$body.event.data.new.date_exported}},\r\n \"status\": {{$body.event.data.new.status}},\r\n \"employee_csr\": {{$body.event.data.new.employee_csr}},\r\n \"actual_in\": {{$body.event.data.new.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.new.deliverchecklist}},\r\n \"comment\": {{$body.event.data.new.comment}},\r\n \"employee_refinish\": {{$body.event.data.new.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.new.inproduction}},\r\n \"production_vars\": {{$body.event.data.new.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.new.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.new.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.new.date_invoiced}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n"
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
@@ -4825,6 +4922,26 @@
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: notifications_notes
|
||||
definition:
|
||||
enable_manual: false
|
||||
insert:
|
||||
columns: '*'
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
timeout_sec: 60
|
||||
webhook_from_env: HASURA_API_URL
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/notifications/events/handleNotesChange'
|
||||
version: 2
|
||||
- table:
|
||||
name: notifications
|
||||
schema: public
|
||||
@@ -4835,46 +4952,79 @@
|
||||
- name: job
|
||||
using:
|
||||
foreign_key_constraint_on: jobid
|
||||
insert_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
check:
|
||||
job:
|
||||
bodyshop:
|
||||
associations:
|
||||
_and:
|
||||
- user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
columns:
|
||||
- scenario_meta
|
||||
- scenario_text
|
||||
- fcm_text
|
||||
- created_at
|
||||
- read
|
||||
- updated_at
|
||||
- associationid
|
||||
- id
|
||||
- jobid
|
||||
comment: ""
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- associationid
|
||||
- scenario_meta
|
||||
- scenario_text
|
||||
- fcm_text
|
||||
- created_at
|
||||
- fcm_data
|
||||
- fcm_message
|
||||
- fcm_title
|
||||
- read
|
||||
- updated_at
|
||||
- associationid
|
||||
- id
|
||||
- jobid
|
||||
- meta
|
||||
- read
|
||||
- ui_translation_meta
|
||||
- ui_translation_string
|
||||
- updated_at
|
||||
filter:
|
||||
association:
|
||||
_and:
|
||||
- active:
|
||||
_eq: true
|
||||
- user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
job:
|
||||
bodyshop:
|
||||
associations:
|
||||
_and:
|
||||
- user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
allow_aggregations: true
|
||||
comment: ""
|
||||
update_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- meta
|
||||
- scenario_meta
|
||||
- scenario_text
|
||||
- fcm_text
|
||||
- created_at
|
||||
- read
|
||||
filter:
|
||||
association:
|
||||
_and:
|
||||
- active:
|
||||
_eq: true
|
||||
- user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
check: null
|
||||
- updated_at
|
||||
- associationid
|
||||
- id
|
||||
- jobid
|
||||
filter: {}
|
||||
check:
|
||||
job:
|
||||
bodyshop:
|
||||
associations:
|
||||
_and:
|
||||
- user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
comment: ""
|
||||
- table:
|
||||
name: owners
|
||||
@@ -5648,6 +5798,25 @@
|
||||
- active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: notifications_payments
|
||||
definition:
|
||||
enable_manual: false
|
||||
insert:
|
||||
columns: '*'
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
timeout_sec: 60
|
||||
webhook_from_env: HASURA_API_URL
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/notifications/events/handlePaymentsChange'
|
||||
version: 2
|
||||
- name: os_payments
|
||||
definition:
|
||||
delete:
|
||||
@@ -6119,9 +6288,15 @@
|
||||
columns: '*'
|
||||
update:
|
||||
columns:
|
||||
- joblineid
|
||||
- assigned_to
|
||||
- due_date
|
||||
- partsorderid
|
||||
- completed
|
||||
- description
|
||||
- billid
|
||||
- title
|
||||
- priority
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
@@ -6131,12 +6306,6 @@
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
body:
|
||||
action: transform
|
||||
template: |-
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
@@ -6313,12 +6482,6 @@
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
body:
|
||||
action: transform
|
||||
template: |-
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
@@ -6586,6 +6749,13 @@
|
||||
table:
|
||||
name: ioevents
|
||||
schema: public
|
||||
- name: job_watchers
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: user_email
|
||||
table:
|
||||
name: job_watchers
|
||||
schema: public
|
||||
- name: messages
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."notifications" add column "html_body" text
|
||||
-- not null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."notifications" add column "html_body" text
|
||||
not null;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" alter column "fcm_title" set not null;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" alter column "fcm_title" drop not null;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" alter column "fcm_message" set not null;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" alter column "fcm_message" drop not null;
|
||||
@@ -0,0 +1,3 @@
|
||||
comment on column "public"."notifications"."html_body" is E'Real Time Notifications System';
|
||||
alter table "public"."notifications" alter column "html_body" drop not null;
|
||||
alter table "public"."notifications" add column "html_body" text;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" drop column "html_body" cascade;
|
||||
@@ -0,0 +1,4 @@
|
||||
comment on column "public"."notifications"."fcm_data" is E'Real Time Notifications System';
|
||||
alter table "public"."notifications" alter column "fcm_data" set default jsonb_build_object();
|
||||
alter table "public"."notifications" alter column "fcm_data" drop not null;
|
||||
alter table "public"."notifications" add column "fcm_data" jsonb;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" drop column "fcm_data" cascade;
|
||||
@@ -0,0 +1,3 @@
|
||||
comment on column "public"."notifications"."fcm_message" is E'Real Time Notifications System';
|
||||
alter table "public"."notifications" alter column "fcm_message" drop not null;
|
||||
alter table "public"."notifications" add column "fcm_message" text;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" drop column "fcm_message" cascade;
|
||||
@@ -0,0 +1,3 @@
|
||||
comment on column "public"."notifications"."ui_translation_string" is E'Real Time Notifications System';
|
||||
alter table "public"."notifications" alter column "ui_translation_string" drop not null;
|
||||
alter table "public"."notifications" add column "ui_translation_string" text;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" drop column "ui_translation_string" cascade;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" rename column "fcm_text" to "fcm_title";
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" rename column "fcm_title" to "fcm_text";
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" rename column "scenario_text" to "ui_translation_meta";
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" rename column "ui_translation_meta" to "scenario_text";
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" rename column "scenario_meta" to "meta";
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" rename column "meta" to "scenario_meta";
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS "public"."idx_job_watchers_jobid_user_email_unique";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE UNIQUE INDEX "idx_job_watchers_jobid_user_email_unique" on
|
||||
"public"."job_watchers" using btree ("jobid", "user_email");
|
||||
45
nginx-websocket.conf
Normal file
45
nginx-websocket.conf
Normal file
@@ -0,0 +1,45 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
upstream node_app {
|
||||
ip_hash; # Enables session persistence based on client IP
|
||||
server node-app-1:4000;
|
||||
server node-app-2:4000;
|
||||
server node-app-3:4000;
|
||||
}
|
||||
|
||||
# WebSocket upgrade configuration
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location / {
|
||||
proxy_pass http://node_app;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket headers
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 86400; # Keep WebSocket connections alive (24 hours)
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
proxy_pass http://node_app;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
1320
package-lock.json
generated
1320
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
42
package.json
42
package.json
@@ -19,39 +19,41 @@
|
||||
"makeitpretty": "prettier --write \"**/*.{css,js,json,jsx,scss}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.738.0",
|
||||
"@aws-sdk/client-elasticache": "^3.738.0",
|
||||
"@aws-sdk/client-s3": "^3.738.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.738.0",
|
||||
"@aws-sdk/client-ses": "^3.738.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.738.0",
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.758.0",
|
||||
"@aws-sdk/client-elasticache": "^3.758.0",
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.758.0",
|
||||
"@aws-sdk/client-ses": "^3.758.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.758.0",
|
||||
"@opensearch-project/opensearch": "^2.13.0",
|
||||
"@socket.io/admin-ui": "^0.5.1",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"aws4": "^1.13.2",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.8.1",
|
||||
"bee-queue": "^1.7.1",
|
||||
"better-queue": "^3.8.12",
|
||||
"bluebird": "^3.7.2",
|
||||
"body-parser": "^1.20.3",
|
||||
"chart.js": "^4.4.6",
|
||||
"bullmq": "^5.41.7",
|
||||
"chart.js": "^4.4.8",
|
||||
"cloudinary": "^2.5.1",
|
||||
"compression": "^1.7.5",
|
||||
"compression": "^1.8.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"crisp-status-reporter": "^1.2.2",
|
||||
"csrf": "^3.1.0",
|
||||
"dd-trace": "^5.33.1",
|
||||
"dd-trace": "^5.40.0",
|
||||
"dinero.js": "^1.9.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.1",
|
||||
"firebase-admin": "^13.0.2",
|
||||
"firebase-admin": "^13.1.0",
|
||||
"graphql": "^16.10.0",
|
||||
"graphql-request": "^6.1.0",
|
||||
"inline-css": "^4.0.3",
|
||||
"intuit-oauth": "^4.1.3",
|
||||
"ioredis": "^5.4.2",
|
||||
"intuit-oauth": "^4.2.0",
|
||||
"ioredis": "^5.5.0",
|
||||
"json-2-csv": "^5.5.8",
|
||||
"juice": "^11.0.0",
|
||||
"juice": "^11.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.47",
|
||||
@@ -64,7 +66,7 @@
|
||||
"redis": "^4.7.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"skia-canvas": "^2.0.2",
|
||||
"soap": "^1.1.7",
|
||||
"soap": "^1.1.9",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-adapter": "^2.5.5",
|
||||
"ssh2-sftp-client": "^11.0.0",
|
||||
@@ -76,14 +78,14 @@
|
||||
"xmlbuilder2": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"globals": "^15.14.0",
|
||||
"globals": "^15.15.0",
|
||||
"p-limit": "^3.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier": "^3.5.3",
|
||||
"source-map-explorer": "^2.5.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ cluster-enabled yes
|
||||
cluster-config-file nodes.conf
|
||||
cluster-node-timeout 5000
|
||||
appendonly yes
|
||||
maxmemory-policy noeviction
|
||||
|
||||
185
server.js
185
server.js
@@ -5,7 +5,7 @@ require("dotenv").config({
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV) {
|
||||
const tracer = require("dd-trace").init({
|
||||
require("dd-trace").init({
|
||||
profiling: true,
|
||||
env: process.env.NODE_ENV,
|
||||
service: "bodyshop-api"
|
||||
@@ -28,12 +28,18 @@ const logger = require("./server/utils/logger");
|
||||
const { applyRedisHelpers } = require("./server/utils/redisHelpers");
|
||||
const { applyIOHelpers } = require("./server/utils/ioHelpers");
|
||||
const { redisSocketEvents } = require("./server/web-sockets/redisSocketEvents");
|
||||
const { ElastiCacheClient, DescribeCacheClustersCommand } = require("@aws-sdk/client-elasticache");
|
||||
const {
|
||||
ElastiCacheClient,
|
||||
DescribeCacheClustersCommand,
|
||||
DescribeReplicationGroupsCommand
|
||||
} = require("@aws-sdk/client-elasticache");
|
||||
const { InstanceRegion } = require("./server/utils/instanceMgr");
|
||||
const StartStatusReporter = require("./server/utils/statusReporter");
|
||||
const { registerCleanupTask, initializeCleanupManager } = require("./server/utils/cleanupManager");
|
||||
|
||||
const { loadEmailQueue } = require("./server/notifications/queues/emailQueue");
|
||||
const { loadAppQueue } = require("./server/notifications/queues/appQueue");
|
||||
|
||||
const cleanupTasks = [];
|
||||
let isShuttingDown = false;
|
||||
const CLUSTER_RETRY_BASE_DELAY = 100;
|
||||
const CLUSTER_RETRY_MAX_DELAY = 5000;
|
||||
const CLUSTER_RETRY_JITTER = 100;
|
||||
@@ -58,7 +64,7 @@ const SOCKETIO_CORS_ORIGIN = [
|
||||
"https://beta.test.imex.online",
|
||||
"https://www.beta.test.imex.online",
|
||||
"https://beta.imex.online",
|
||||
"https://www.beta.imex.online",
|
||||
"https://www.beta.imex.online",
|
||||
"https://www.test.promanager.web-est.com",
|
||||
"https://test.promanager.web-est.com",
|
||||
"https://www.promanager.web-est.com",
|
||||
@@ -124,26 +130,48 @@ const applyRoutes = ({ app }) => {
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
const getRedisNodesFromAWS = async () => {
|
||||
const client = new ElastiCacheClient({
|
||||
region: InstanceRegion()
|
||||
});
|
||||
|
||||
const params = {
|
||||
ReplicationGroupId: process.env.REDIS_CLUSTER_ID,
|
||||
ShowCacheNodeInfo: true
|
||||
};
|
||||
const client = new ElastiCacheClient({ region: InstanceRegion() });
|
||||
|
||||
try {
|
||||
// Fetch the cache clusters associated with the replication group
|
||||
const command = new DescribeCacheClustersCommand(params);
|
||||
const response = await client.send(command);
|
||||
const cacheClusters = response.CacheClusters;
|
||||
const describeReplicationGroupCommand = new DescribeReplicationGroupsCommand({
|
||||
ReplicationGroupId: process.env.REDIS_CLUSTER_ID
|
||||
});
|
||||
const describeReplicationGroupResponse = await client.send(describeReplicationGroupCommand);
|
||||
|
||||
return cacheClusters.flatMap((cluster) =>
|
||||
cluster.CacheNodes.map((node) => `${node.Endpoint.Address}:${node.Endpoint.Port}`)
|
||||
);
|
||||
//TODO: add checking to make sure there's only 1.
|
||||
const cacheClusterIds = describeReplicationGroupResponse.ReplicationGroups[0].MemberClusters;
|
||||
|
||||
// Ensure cacheClusters exists and is an array
|
||||
if (!cacheClusterIds || !Array.isArray(cacheClusterIds) || cacheClusterIds.length === 0) {
|
||||
logger.log(`No cache clusters found for cluster id ${process.env.REDIS_CLUSTER_ID}`, "ERROR", "redis", "api");
|
||||
return [];
|
||||
}
|
||||
|
||||
const nodeEndpointAddresses = [];
|
||||
|
||||
for (const cluster of cacheClusterIds) {
|
||||
const params = { CacheClusterId: cluster, ShowCacheNodeInfo: true };
|
||||
const command = new DescribeCacheClustersCommand(params);
|
||||
const response = await client.send(command);
|
||||
|
||||
if (response.CacheClusters && Array.isArray(response.CacheClusters)) {
|
||||
// Map nodes to address strings
|
||||
//TODO: What happens if we have more shards?
|
||||
const nodeAddress = `${response.CacheClusters[0].CacheNodes[0].Endpoint.Address}:${response.CacheClusters[0].CacheNodes[0].Endpoint.Port}`;
|
||||
// Debug log node addresses
|
||||
logger.log(`Cluster node addresses: ${nodeAddress}`, "DEBUG", "redis", "api");
|
||||
// Return only those addresses that start with the current cluster id
|
||||
nodeEndpointAddresses.push(nodeAddress);
|
||||
}
|
||||
}
|
||||
|
||||
return nodeEndpointAddresses;
|
||||
// Process each cluster
|
||||
} catch (err) {
|
||||
logger.log(`Error fetching Redis nodes from AWS: ${err.message}`, "ERROR", "redis", "api");
|
||||
logger.log(`Error fetching Redis nodes from AWS:`, "ERROR", "redis", "api", {
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -167,7 +195,10 @@ const connectToRedisCluster = async () => {
|
||||
try {
|
||||
redisServers = JSON.parse(process.env.REDIS_URL);
|
||||
} catch (error) {
|
||||
logger.log(`Failed to parse REDIS_URL: ${error.message}. Exiting...`, "ERROR", "redis", "api");
|
||||
logger.log(`Failed to parse REDIS_URL: ${error.message}. Exiting...`, "ERROR", "redis", "api", {
|
||||
message: error?.message,
|
||||
stack: error?.stack
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -193,11 +224,22 @@ const connectToRedisCluster = async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
redisCluster.on("ready", () => {
|
||||
logger.log(`Redis cluster connection established.`, "INFO", "redis", "api");
|
||||
resolve(redisCluster);
|
||||
if (process.env.NODE_ENV === "development" && process.env?.CLEAR_REDIS_ON_START === "true") {
|
||||
logger.log("[Development] Flushing Redis Cluster on Service start...", "INFO", "redis", "api");
|
||||
const master = redisCluster.nodes("master");
|
||||
Promise.all(master.map((node) => node.flushall())).then(() => {
|
||||
resolve(redisCluster);
|
||||
});
|
||||
} else {
|
||||
resolve(redisCluster);
|
||||
}
|
||||
});
|
||||
|
||||
redisCluster.on("error", (err) => {
|
||||
logger.log(`Redis cluster connection failed: ${err.message}`, "ERROR", "redis", "api");
|
||||
logger.log(`Redis cluster connection failed:`, "ERROR", "redis", "api", {
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
});
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
@@ -219,17 +261,24 @@ const applySocketIO = async ({ server, app }) => {
|
||||
const pubClient = redisCluster;
|
||||
const subClient = pubClient.duplicate();
|
||||
|
||||
pubClient.on("error", (err) => logger.log(`Redis pubClient error: ${err}`, "ERROR", "redis"));
|
||||
subClient.on("error", (err) => logger.log(`Redis subClient error: ${err}`, "ERROR", "redis"));
|
||||
pubClient.on("error", (err) =>
|
||||
logger.log(`Redis pubClient error: ${err}`, "ERROR", "redis", "api", {
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
})
|
||||
);
|
||||
subClient.on("error", (err) =>
|
||||
logger.log(`Redis subClient error: ${err}`, "ERROR", "redis", "api", {
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
})
|
||||
);
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
// Register Redis cleanup
|
||||
registerCleanupTask(async () => {
|
||||
logger.log("Closing Redis connections...", "INFO", "redis", "api");
|
||||
try {
|
||||
await Promise.all([pubClient.disconnect(), subClient.disconnect()]);
|
||||
logger.log("Redis connections closed. Process will exit.", "INFO", "redis", "api");
|
||||
} catch (error) {
|
||||
logger.log(`Error closing Redis connections: ${error.message}`, "ERROR", "redis", "api");
|
||||
}
|
||||
await Promise.all([pubClient.disconnect(), subClient.disconnect()]);
|
||||
logger.log("Redis connections closed.", "INFO", "redis", "api");
|
||||
});
|
||||
|
||||
const ioRedis = new Server(server, {
|
||||
@@ -287,6 +336,34 @@ const applySocketIO = async ({ server, app }) => {
|
||||
return api;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load Queues for Email and App
|
||||
* @param {Object} options - Queue configuration options
|
||||
* @param {Redis.Cluster} options.pubClient - Redis client for publishing
|
||||
* @param {Object} options.logger - Logger instance
|
||||
* @param {Object} options.redisHelpers - Redis helper functions
|
||||
* @param {Server} options.ioRedis - Socket.IO server instance
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
|
||||
const queueSettings = { pubClient, logger, redisHelpers, ioRedis };
|
||||
|
||||
// Assuming loadEmailQueue and loadAppQueue return Promises
|
||||
const [notificationsEmailsQueue, notificationsAppQueue] = await Promise.all([
|
||||
loadEmailQueue(queueSettings),
|
||||
loadAppQueue(queueSettings)
|
||||
]);
|
||||
|
||||
// Add error listeners or other setup for queues if needed
|
||||
notificationsEmailsQueue.on("error", (error) => {
|
||||
logger.log(`Error in notificationsEmailsQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
|
||||
});
|
||||
|
||||
notificationsAppQueue.on("error", (error) => {
|
||||
logger.log(`Error in notificationsAppQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Main function to start the server
|
||||
* @returns {Promise<void>}
|
||||
@@ -297,6 +374,9 @@ const main = async () => {
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Initialize cleanup manager with signal handlers
|
||||
initializeCleanupManager();
|
||||
|
||||
const { pubClient, ioRedis } = await applySocketIO({ server, app });
|
||||
const redisHelpers = applyRedisHelpers({ pubClient, app, logger });
|
||||
const ioHelpers = applyIOHelpers({ app, redisHelpers, ioRedis, logger });
|
||||
@@ -304,6 +384,9 @@ const main = async () => {
|
||||
// Legacy Socket Events
|
||||
require("./server/web-sockets/web-socket");
|
||||
|
||||
// Initialize Queues
|
||||
await loadQueues({ pubClient: pubClient, logger, redisHelpers, ioRedis });
|
||||
|
||||
applyMiddleware({ app });
|
||||
applyRoutes({ app });
|
||||
redisSocketEvents({ io: ioRedis, redisHelpers, ioHelpers, logger });
|
||||
@@ -313,15 +396,11 @@ const main = async () => {
|
||||
StatusReporter.end();
|
||||
});
|
||||
|
||||
// Add SIGTERM signal handler
|
||||
process.on("SIGTERM", handleSigterm);
|
||||
process.on("SIGINT", handleSigterm); // Optional: Handle Ctrl+C
|
||||
|
||||
try {
|
||||
await server.listen(port);
|
||||
logger.log(`Server started on port ${port}`, "INFO", "api");
|
||||
} catch (error) {
|
||||
logger.log(`Server failed to start on port ${port}`, "ERROR", "api", error);
|
||||
logger.log(`Server failed to start on port ${port}`, "ERROR", "api", null, { error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -335,33 +414,3 @@ 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);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const OAuthClient = require("intuit-oauth");
|
||||
const client = require("../../graphql-client/graphql-client").client;
|
||||
const queries = require("../../graphql-client/queries");
|
||||
const { parse, stringify } = require("querystring");
|
||||
const InstanceManager = require("../../utils/instanceMgr").default;
|
||||
const { InstanceEndpoints } = require("../../utils/instanceMgr");
|
||||
|
||||
const oauthClient = new OAuthClient({
|
||||
clientId: process.env.QBO_CLIENT_ID,
|
||||
@@ -17,16 +17,8 @@ const oauthClient = new OAuthClient({
|
||||
logging: true
|
||||
});
|
||||
|
||||
let url;
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
//TODO:AIO Add in QBO callbacks.
|
||||
url = InstanceManager({ imex: `https://imex.online`, rome: `https://romeonline.io` });
|
||||
} else if (process.env.NODE_ENV === "test") {
|
||||
url = InstanceManager({ imex: `https://test.imex.online`, rome: `https://test.romeonline.io` });
|
||||
} else {
|
||||
url = `http://localhost:3000`;
|
||||
}
|
||||
//TODO:AIO Add in QBO callbacks.
|
||||
const url = InstanceEndpoints();
|
||||
|
||||
exports.default = async (req, res) => {
|
||||
const queryString = req.url.split("?").reverse()[0];
|
||||
|
||||
@@ -69,11 +69,14 @@ const sendServerEmail = async ({ subject, text }) => {
|
||||
}
|
||||
},
|
||||
(err, info) => {
|
||||
logger.log("server-email-failure", err ? "error" : "debug", null, null, { message: 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, { error });
|
||||
logger.log("server-email-failure", "error", null, null, { message: error?.message, stack: error?.stack });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,11 +95,11 @@ const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachmen
|
||||
},
|
||||
(err, info) => {
|
||||
// (message, type, user, record, meta
|
||||
logger.log("server-email", err ? "error" : "debug", null, null, { message: err ? err?.message : info });
|
||||
logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message, stack: err?.stack });
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.log("server-email-failure", "error", null, null, { error });
|
||||
logger.log("server-email-failure", "error", null, null, { message: error?.message, stack: error?.stack });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -125,7 +128,8 @@ const sendEmail = async (req, res) => {
|
||||
cc: req.body.cc,
|
||||
subject: req.body.subject,
|
||||
templateStrings: req.body.templateStrings,
|
||||
error
|
||||
errorMessage: error?.message,
|
||||
errorStack: error?.stack
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -194,7 +198,8 @@ const sendEmail = async (req, res) => {
|
||||
cc: req.body.cc,
|
||||
subject: req.body.subject,
|
||||
templateStrings: req.body.templateStrings,
|
||||
error: err
|
||||
errorMessage: err?.message,
|
||||
errorStack: err?.stack
|
||||
});
|
||||
logEmail(req, {
|
||||
to: req.body.to,
|
||||
@@ -202,7 +207,7 @@ const sendEmail = async (req, res) => {
|
||||
subject: req.body.subject,
|
||||
bodyshopid: req.body.bodyshopid
|
||||
});
|
||||
res.status(500).json({ success: false, error: err });
|
||||
res.status(500).json({ success: false, errorMessage: err?.message, stack: err?.stack });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -270,14 +275,16 @@ ${body.bounce?.bouncedRecipients.map(
|
||||
},
|
||||
(err, info) => {
|
||||
logger.log("sns-error", err ? "error" : "debug", "api", null, {
|
||||
message: err ? err?.message : info
|
||||
errorMessage: err?.message,
|
||||
errorStack: err?.stack
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log("sns-error", "ERROR", "api", null, {
|
||||
error: JSON.stringify(error)
|
||||
errorMessage: error?.message,
|
||||
errorStack: error?.stack
|
||||
});
|
||||
}
|
||||
res.sendStatus(200);
|
||||
|
||||
@@ -10,6 +10,8 @@ const generateEmailTemplate = require("./generateTemplate");
|
||||
const moment = require("moment-timezone");
|
||||
const { taskEmailQueue } = require("./tasksEmailsQueue");
|
||||
const mailer = require("./mailer");
|
||||
const { InstanceEndpoints } = require("../utils/instanceMgr");
|
||||
const { formatTaskPriority } = require("../notifications/stringHelpers");
|
||||
|
||||
// Initialize the Tasks Email Queue
|
||||
const tasksEmailQueue = taskEmailQueue();
|
||||
@@ -61,16 +63,6 @@ const formatDate = (date) => {
|
||||
return date ? `| Due on: ${moment(date).format("MM/DD/YYYY")}` : "";
|
||||
};
|
||||
|
||||
const formatPriority = (priority) => {
|
||||
if (priority === 1) {
|
||||
return "High";
|
||||
} else if (priority === 3) {
|
||||
return "Low";
|
||||
} else {
|
||||
return "Medium";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the email template arguments.
|
||||
* @param title
|
||||
@@ -83,18 +75,11 @@ const formatPriority = (priority) => {
|
||||
* @param taskId
|
||||
* @returns {{header, body: string, subHeader: string}}
|
||||
*/
|
||||
|
||||
const getEndpoints = (bodyshop) =>
|
||||
InstanceManager({
|
||||
imex: process.env?.NODE_ENV === "test" ? "https://test.imex.online" : "https://imex.online",
|
||||
rome: process.env?.NODE_ENV === "test" ? "https//test.romeonline.io" : "https://romeonline.io"
|
||||
});
|
||||
|
||||
const generateTemplateArgs = (title, priority, description, dueDate, bodyshop, job, taskId, dateLine, createdBy) => {
|
||||
const endPoints = getEndpoints(bodyshop);
|
||||
const endPoints = InstanceEndpoints();
|
||||
return {
|
||||
header: title,
|
||||
subHeader: `Body Shop: ${bodyshop.shopname} | Priority: ${formatPriority(priority)} ${formatDate(dueDate)} | Created By: ${createdBy || "N/A"}`,
|
||||
subHeader: `Body Shop: ${bodyshop.shopname} | Priority: ${formatTaskPriority(priority)} ${formatDate(dueDate)} | Created By: ${createdBy || "N/A"}`,
|
||||
body: `Reference: ${job.ro_number || "N/A"} | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()}<br>${description ? description.concat("<br>") : ""}<a href="${endPoints}/manage/tasks/alltasks?taskid=${taskId}">View this task.</a>`,
|
||||
dateLine
|
||||
};
|
||||
@@ -108,9 +93,8 @@ const generateTemplateArgs = (title, priority, description, dueDate, bodyshop, j
|
||||
* @param html
|
||||
* @param taskIds
|
||||
* @param successCallback
|
||||
* @param requestInstance
|
||||
*/
|
||||
const sendMail = (type, to, subject, html, taskIds, successCallback, requestInstance) => {
|
||||
const sendMail = (type, to, subject, html, taskIds, successCallback) => {
|
||||
const fromEmails = InstanceManager({
|
||||
imex: "ImEX Online <noreply@imex.online>",
|
||||
rome: "Rome Online <noreply@romeonline.io>"
|
||||
@@ -136,7 +120,7 @@ const sendMail = (type, to, subject, html, taskIds, successCallback, requestInst
|
||||
};
|
||||
|
||||
/**
|
||||
* Send an email to the assigned user.
|
||||
* Email the assigned user.
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns {Promise<*>}
|
||||
@@ -162,7 +146,7 @@ const taskAssignedEmail = async (req, res) => {
|
||||
sendMail(
|
||||
"assigned",
|
||||
tasks_by_pk.assigned_to_employee.user_email,
|
||||
`A ${formatPriority(newTask.priority)} priority task has been ${dirty ? "reassigned to" : "created for"} you - ${newTask.title}`,
|
||||
`A ${formatTaskPriority(newTask.priority)} priority task has been ${dirty ? "reassigned to" : "created for"} you - ${newTask.title}`,
|
||||
generateEmailTemplate(
|
||||
generateTemplateArgs(
|
||||
newTask.title,
|
||||
@@ -186,7 +170,7 @@ const taskAssignedEmail = async (req, res) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Send an email to remind the user of their tasks.
|
||||
* Email remind the user of their tasks.
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns {Promise<*>}
|
||||
@@ -246,7 +230,7 @@ const tasksRemindEmail = async (req, res) => {
|
||||
const onlyTask = groupedTasks[recipient.email][0];
|
||||
|
||||
emailData.subject =
|
||||
`New ${formatPriority(onlyTask.priority)} Priority Task Reminder - ${onlyTask.title} ${onlyTask.due_date ? `- ${formatDate(onlyTask.due_date)}` : ""}`.trim();
|
||||
`New ${formatTaskPriority(onlyTask.priority)} Priority Task Reminder - ${onlyTask.title} ${onlyTask.due_date ? `- ${formatDate(onlyTask.due_date)}` : ""}`.trim();
|
||||
|
||||
emailData.html = generateEmailTemplate(
|
||||
generateTemplateArgs(
|
||||
@@ -264,11 +248,6 @@ const tasksRemindEmail = async (req, res) => {
|
||||
}
|
||||
// There are multiple emails to send to this author.
|
||||
else {
|
||||
const endPoints = InstanceManager({
|
||||
imex: process.env?.NODE_ENV === "test" ? "https://test.imex.online" : "https://imex.online",
|
||||
rome: process.env?.NODE_ENV === "test" ? "https//test.romeonline.io" : "https://romeonline.io"
|
||||
});
|
||||
|
||||
const allTasks = groupedTasks[recipient.email];
|
||||
emailData.subject = `New Tasks Reminder - ${allTasks.length} Tasks require your attention`;
|
||||
emailData.html = generateEmailTemplate({
|
||||
@@ -278,7 +257,7 @@ const tasksRemindEmail = async (req, res) => {
|
||||
body: `<ul>
|
||||
${allTasks
|
||||
.map((task) =>
|
||||
`<li><a href="${endPoints}/manage/tasks/alltasks?taskid=${task.id}">${task.title} - Priority: ${formatPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a></li>`.trim()
|
||||
`<li><a href="${InstanceEndpoints()}/manage/tasks/alltasks?taskid=${task.id}">${task.title} - Priority: ${formatTaskPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a></li>`.trim()
|
||||
)
|
||||
.join("")}
|
||||
</ul>`
|
||||
@@ -338,6 +317,5 @@ const tasksRemindEmail = async (req, res) => {
|
||||
|
||||
module.exports = {
|
||||
taskAssignedEmail,
|
||||
tasksRemindEmail,
|
||||
getEndpoints
|
||||
tasksRemindEmail
|
||||
};
|
||||
|
||||
@@ -2705,3 +2705,67 @@ exports.INSERT_AUDIT_TRAIL = `
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
exports.GET_JOB_WATCHERS = `
|
||||
query GET_JOB_WATCHERS($jobid: uuid!) {
|
||||
job_watchers(where: { jobid: { _eq: $jobid } }) {
|
||||
user_email
|
||||
user {
|
||||
authid
|
||||
employee {
|
||||
id
|
||||
first_name
|
||||
last_name
|
||||
}
|
||||
}
|
||||
}
|
||||
job: jobs_by_pk(id: $jobid) {
|
||||
id
|
||||
ro_number
|
||||
clm_no
|
||||
bodyshop {
|
||||
id
|
||||
shopname
|
||||
timezone
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
exports.GET_NOTIFICATION_ASSOCIATIONS = `
|
||||
query GET_NOTIFICATION_ASSOCIATIONS($emails: [String!]!, $shopid: uuid!) {
|
||||
associations(where: {
|
||||
useremail: { _in: $emails },
|
||||
shopid: { _eq: $shopid }
|
||||
}) {
|
||||
id
|
||||
useremail
|
||||
notification_settings
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
exports.INSERT_NOTIFICATIONS_MUTATION = ` mutation INSERT_NOTIFICATIONS($objects: [notifications_insert_input!]!) {
|
||||
insert_notifications(objects: $objects) {
|
||||
affected_rows
|
||||
returning {
|
||||
id
|
||||
jobid
|
||||
associationid
|
||||
scenario_text
|
||||
fcm_text
|
||||
scenario_meta
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
// REMEMBER: Update the cache_bodyshop event in hasura to include any added fields
|
||||
exports.GET_BODYSHOP_BY_ID = `
|
||||
query GET_BODYSHOP_BY_ID($id: uuid!) {
|
||||
bodyshops_by_pk(id: $id) {
|
||||
id
|
||||
md_order_statuses
|
||||
shopname
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -10,12 +10,11 @@ const moment = require("moment");
|
||||
const logger = require("../utils/logger");
|
||||
const { sendTaskEmail } = require("../email/sendemail");
|
||||
const generateEmailTemplate = require("../email/generateTemplate");
|
||||
const { getEndpoints } = require("../email/tasksEmails");
|
||||
|
||||
const domain = process.env.NODE_ENV ? "secure" : "test";
|
||||
|
||||
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
|
||||
const { InstanceRegion } = require("../utils/instanceMgr");
|
||||
const { InstanceRegion, InstanceEndpoints } = require("../utils/instanceMgr");
|
||||
|
||||
const client = new SecretsManagerClient({
|
||||
region: InstanceRegion()
|
||||
@@ -443,31 +442,28 @@ exports.postback = async (req, res) => {
|
||||
});
|
||||
|
||||
if (values.origin === "OneLink" && parsedComment.userEmail) {
|
||||
try {
|
||||
const endPoints = getEndpoints();
|
||||
sendTaskEmail({
|
||||
to: parsedComment.userEmail,
|
||||
subject: `New Payment(s) Received - RO ${jobs.jobs.map((j) => j.ro_number).join(", ")}`,
|
||||
type: "html",
|
||||
html: generateEmailTemplate({
|
||||
header: "New Payment(s) Received",
|
||||
subHeader: "",
|
||||
body: jobs.jobs
|
||||
.map(
|
||||
(job) =>
|
||||
`Reference: <a href="${endPoints}/manage/jobs/${job.id}">${job.ro_number || "N/A"}</a> | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}`
|
||||
)
|
||||
.join("<br/>")
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
sendTaskEmail({
|
||||
to: parsedComment.userEmail,
|
||||
subject: `New Payment(s) Received - RO ${jobs.jobs.map((j) => j.ro_number).join(", ")}`,
|
||||
type: "html",
|
||||
html: generateEmailTemplate({
|
||||
header: "New Payment(s) Received",
|
||||
subHeader: "",
|
||||
body: jobs.jobs
|
||||
.map(
|
||||
(job) =>
|
||||
`Reference: <a href="${InstanceEndpoints()}/manage/jobs/${job.id}">${job.ro_number || "N/A"}</a> | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}`
|
||||
)
|
||||
.join("<br/>")
|
||||
})
|
||||
}).catch((error) => {
|
||||
logger.log("intellipay-postback-email-error", "ERROR", req.user?.email, null, {
|
||||
message: error.message,
|
||||
jobs,
|
||||
paymentResult,
|
||||
...logResponseMeta
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
res.sendStatus(200);
|
||||
} else if (values.invoice) {
|
||||
|
||||
196
server/notifications/eventHandlers.js
Normal file
196
server/notifications/eventHandlers.js
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* @fileoverview Notification event handlers.
|
||||
* This module exports functions to handle various notification events.
|
||||
* Each handler optionally calls the scenarioParser and logs errors if they occur,
|
||||
* then returns a JSON response with a success message.
|
||||
*/
|
||||
|
||||
const scenarioParser = require("./scenarioParser");
|
||||
|
||||
/**
|
||||
* Processes a notification event by invoking the scenario parser.
|
||||
* The scenarioParser is intentionally not awaited so that the response is sent immediately.
|
||||
*
|
||||
* @param {Object} req - Express request object.
|
||||
* @param {Object} res - Express response object.
|
||||
* @param {string} parserPath - The key path to be passed to scenarioParser.
|
||||
* @param {string} successMessage - The message to return on success.
|
||||
* @returns {Promise<Object>} A promise that resolves to an Express JSON response.
|
||||
*/
|
||||
async function processNotificationEvent(req, res, parserPath, successMessage) {
|
||||
const { logger } = req;
|
||||
|
||||
// Call scenarioParser but don't await it; log any error that occurs.
|
||||
scenarioParser(req, parserPath).catch((error) => {
|
||||
logger.log("notifications-error", "error", "notifications", null, { message: error?.message, stack: error?.stack });
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: successMessage });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle job change notifications.
|
||||
*
|
||||
* @param {Object} req - Express request object.
|
||||
* @param {Object} res - Express response object.
|
||||
* @returns {Promise<Object>} JSON response with a success message.
|
||||
*/
|
||||
const handleJobsChange = async (req, res) =>
|
||||
processNotificationEvent(req, res, "req.body.event.new.id", "Job Notifications Event Handled.");
|
||||
|
||||
/**
|
||||
* Handle bills change notifications.
|
||||
*
|
||||
* @param {Object} req - Express request object.
|
||||
* @param {Object} res - Express response object.
|
||||
* @returns {Promise<Object>} JSON response with a success message.
|
||||
*/
|
||||
const handleBillsChange = async (req, res) =>
|
||||
processNotificationEvent(req, res, "req.body.event.new.jobid", "Bills Changed Notification Event Handled.");
|
||||
|
||||
/**
|
||||
* Handle documents change notifications.
|
||||
* Processes both old and new job IDs if the document was moved between jobs.
|
||||
*
|
||||
* @param {Object} req - Express request object.
|
||||
* @param {Object} res - Express response object.
|
||||
* @returns {Promise<Object>} JSON response with a success message.
|
||||
*/
|
||||
const handleDocumentsChange = async (req, res) => {
|
||||
const { logger } = req;
|
||||
const newJobId = req.body?.event?.data?.new?.jobid;
|
||||
const oldJobId = req.body?.event?.data?.old?.jobid;
|
||||
|
||||
// If jobid changed (document moved between jobs), we need to notify both jobs
|
||||
if (oldJobId && newJobId && oldJobId !== newJobId) {
|
||||
// Process notification for new job ID
|
||||
scenarioParser(req, "req.body.event.new.jobid").catch((error) => {
|
||||
logger.log("notifications-error", "error", "notifications", null, {
|
||||
message: error?.message,
|
||||
stack: error?.stack
|
||||
});
|
||||
});
|
||||
|
||||
// Create a modified request for old job ID
|
||||
const oldJobReq = {
|
||||
body: {
|
||||
...req.body,
|
||||
event: {
|
||||
...req.body.event,
|
||||
data: {
|
||||
new: {
|
||||
...req.body.event.data.old,
|
||||
// Add a flag to indicate this document was moved away
|
||||
_documentMoved: true,
|
||||
_movedToJob: newJobId
|
||||
},
|
||||
old: null
|
||||
}
|
||||
}
|
||||
},
|
||||
logger,
|
||||
sessionUtils: req.sessionUtils
|
||||
};
|
||||
|
||||
// Process notification for old job ID using the modified request
|
||||
scenarioParser(oldJobReq, "req.body.event.new.jobid").catch((error) => {
|
||||
logger.log("notifications-error", "error", "notifications", null, {
|
||||
message: error?.message,
|
||||
stack: error?.stack
|
||||
});
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "Documents Change Notifications Event Handled for both jobs." });
|
||||
}
|
||||
|
||||
// Otherwise just process the new job ID
|
||||
scenarioParser(req, "req.body.event.new.jobid").catch((error) => {
|
||||
logger.log("notifications-error", "error", "notifications", null, {
|
||||
message: error?.message,
|
||||
stack: error?.stack
|
||||
});
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "Documents Change Notifications Event Handled." });
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle job lines change notifications.
|
||||
*
|
||||
* @param {Object} req - Express request object.
|
||||
* @param {Object} res - Express response object.
|
||||
* @returns {Promise<Object>} JSON response with a success message.
|
||||
*/
|
||||
const handleJobLinesChange = async (req, res) =>
|
||||
processNotificationEvent(req, res, "req.body.event.new.jobid", "JobLines Change Notifications Event Handled.");
|
||||
|
||||
/**
|
||||
* Handle notes change notifications.
|
||||
*
|
||||
* @param {Object} req - Express request object.
|
||||
* @param {Object} res - Express response object.
|
||||
* @returns {Promise<Object>} JSON response with a success message.
|
||||
*/
|
||||
const handleNotesChange = async (req, res) =>
|
||||
processNotificationEvent(req, res, "req.body.event.new.jobid", "Notes Changed Notification Event Handled.");
|
||||
|
||||
/**
|
||||
* Handle parts dispatch change notifications.
|
||||
*
|
||||
* @param {Object} req - Express request object.
|
||||
* @param {Object} res - Express response object.
|
||||
* @returns {Object} JSON response with a success message.
|
||||
*/
|
||||
const handlePartsDispatchChange = (req, res) => res.status(200).json({ message: "Parts Dispatch change handled." });
|
||||
|
||||
/**
|
||||
* Handle parts order change notifications.
|
||||
*
|
||||
* @param {Object} req - Express request object.
|
||||
* @param {Object} res - Express response object.
|
||||
* @returns {Object} JSON response with a success message.
|
||||
*/
|
||||
const handlePartsOrderChange = (req, res) => res.status(200).json({ message: "Parts Order change handled." });
|
||||
|
||||
/**
|
||||
* Handle payments change notifications.
|
||||
*
|
||||
* @param {Object} req - Express request object.
|
||||
* @param {Object} res - Express response object.
|
||||
* @returns {Promise<Object>} JSON response with a success message.
|
||||
*/
|
||||
const handlePaymentsChange = async (req, res) =>
|
||||
processNotificationEvent(req, res, "req.body.event.new.jobid", "Payments Changed Notification Event Handled.");
|
||||
|
||||
/**
|
||||
* Handle tasks change notifications.
|
||||
*
|
||||
* @param {Object} req - Express request object.
|
||||
* @param {Object} res - Express response object.
|
||||
* @returns {Promise<Object>} JSON response with a success message.
|
||||
*/
|
||||
const handleTasksChange = async (req, res) =>
|
||||
processNotificationEvent(req, res, "req.body.event.new.jobid", "Tasks Notifications Event Handled.");
|
||||
|
||||
/**
|
||||
* Handle time tickets change notifications.
|
||||
*
|
||||
* @param {Object} req - Express request object.
|
||||
* @param {Object} res - Express response object.
|
||||
* @returns {Promise<Object>} JSON response with a success message.
|
||||
*/
|
||||
const handleTimeTicketsChange = async (req, res) =>
|
||||
processNotificationEvent(req, res, "req.body.event.new.jobid", "Time Tickets Changed Notification Event Handled.");
|
||||
|
||||
module.exports = {
|
||||
handleJobsChange,
|
||||
handleBillsChange,
|
||||
handleDocumentsChange,
|
||||
handleJobLinesChange,
|
||||
handleNotesChange,
|
||||
handlePartsDispatchChange,
|
||||
handlePartsOrderChange,
|
||||
handlePaymentsChange,
|
||||
handleTasksChange,
|
||||
handleTimeTicketsChange
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
const handleJobsChange = (req, res) => {
|
||||
return res.status(200).json({ message: "Jobs change handled." });
|
||||
};
|
||||
|
||||
module.exports = handleJobsChange;
|
||||
@@ -1,5 +0,0 @@
|
||||
const handleBillsChange = (req, res) => {
|
||||
return res.status(200).json({ message: "Bills change handled." });
|
||||
};
|
||||
|
||||
module.exports = handleBillsChange;
|
||||
@@ -1,5 +0,0 @@
|
||||
const handlePartsDispatchChange = (req, res) => {
|
||||
return res.status(200).json({ message: "Parts Dispatch change handled." });
|
||||
};
|
||||
|
||||
module.exports = handlePartsDispatchChange;
|
||||
@@ -1,5 +0,0 @@
|
||||
const handlePartsOrderChange = (req, res) => {
|
||||
return res.status(200).json({ message: "Parts Order change handled." });
|
||||
};
|
||||
|
||||
module.exports = handlePartsOrderChange;
|
||||
@@ -1,5 +0,0 @@
|
||||
const handleTasksChange = (req, res) => {
|
||||
return res.status(200).json({ message: "Tasks change handled." });
|
||||
};
|
||||
|
||||
module.exports = handleTasksChange;
|
||||
@@ -1,5 +0,0 @@
|
||||
const handleTimeTicketsChange = (req, res) => {
|
||||
return res.status(200).json({ message: "Time Tickets change handled." });
|
||||
};
|
||||
|
||||
module.exports = handleTimeTicketsChange;
|
||||
74
server/notifications/eventParser.js
Normal file
74
server/notifications/eventParser.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Parses an event by comparing old and new data to determine which fields have changed.
|
||||
*
|
||||
* This function analyzes the differences between previous (`oldData`) and current (`newData`)
|
||||
* data states to identify changed fields. It determines if the event is a new entry or an update
|
||||
* and returns details about changed fields, the event type, and associated metadata.
|
||||
*
|
||||
* @param {Object} options - Configuration options for parsing the event.
|
||||
* @param {Object} [options.oldData] - The previous state of the data (undefined for new entries).
|
||||
* @param {Object} options.newData - The current state of the data.
|
||||
* @param {string} options.trigger - The type of event trigger (e.g., 'INSERT', 'UPDATE').
|
||||
* @param {string} options.table - The name of the table associated with the event.
|
||||
* @param {string} [options.jobId] - The job ID, if already extracted by the caller (optional).
|
||||
* @returns {Object} An object containing the parsed event details:
|
||||
* - {Array<string>} changedFieldNames - List of field names that have changed.
|
||||
* - {Object} changedFields - Map of changed fields with their old and new values.
|
||||
* - {boolean} isNew - True if the event is a new entry (no oldData provided).
|
||||
* - {Object} data - The current data state (`newData`).
|
||||
* - {string} trigger - The event trigger type.
|
||||
* - {string} table - The table name.
|
||||
* - {string|null} jobId - The provided jobId or null if not provided.
|
||||
*/
|
||||
const eventParser = async ({ oldData, newData, trigger, table, jobId = null }) => {
|
||||
const isNew = !oldData; // True if no old data exists, indicating a new entry
|
||||
let changedFields = {};
|
||||
let changedFieldNames = [];
|
||||
|
||||
if (isNew) {
|
||||
// For new entries, all fields in newData are considered "changed" (from undefined to their value)
|
||||
changedFields = Object.fromEntries(
|
||||
Object.entries(newData).map(([key, value]) => [key, { old: undefined, new: value }])
|
||||
);
|
||||
changedFieldNames = Object.keys(newData); // All keys are new
|
||||
} else {
|
||||
// Compare oldData and newData to detect updates
|
||||
for (const key in newData) {
|
||||
if (Object.prototype.hasOwnProperty.call(newData, key)) {
|
||||
// Check if the field is new or its value has changed
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(oldData, key) || // Field didn’t exist before
|
||||
JSON.stringify(oldData[key]) !== JSON.stringify(newData[key]) // Values differ (deep comparison)
|
||||
) {
|
||||
changedFields[key] = {
|
||||
old: oldData[key], // Undefined if field wasn’t in oldData
|
||||
new: newData[key]
|
||||
};
|
||||
changedFieldNames.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Identify fields removed in newData (present in oldData but absent in newData)
|
||||
for (const key in oldData) {
|
||||
if (Object.prototype.hasOwnProperty.call(oldData, key) && !Object.prototype.hasOwnProperty.call(newData, key)) {
|
||||
changedFields[key] = {
|
||||
old: oldData[key],
|
||||
new: null // Mark as removed
|
||||
};
|
||||
changedFieldNames.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changedFieldNames, // Array of fields that changed
|
||||
changedFields, // Object with old/new values for changed fields
|
||||
isNew, // Boolean indicating if this is a new entry
|
||||
data: newData, // Current data state
|
||||
trigger, // Event trigger (e.g., 'INSERT', 'UPDATE')
|
||||
table, // Associated table name
|
||||
jobId // Provided jobId or null
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = eventParser;
|
||||
290
server/notifications/queues/appQueue.js
Normal file
290
server/notifications/queues/appQueue.js
Normal file
@@ -0,0 +1,290 @@
|
||||
const { Queue, Worker } = require("bullmq");
|
||||
const { INSERT_NOTIFICATIONS_MUTATION } = require("../../graphql-client/queries");
|
||||
const { registerCleanupTask } = require("../../utils/cleanupManager");
|
||||
const graphQLClient = require("../../graphql-client/graphql-client").client;
|
||||
|
||||
// Base time-related constant in minutes, sourced from environment variable or defaulting to 1
|
||||
const APP_CONSOLIDATION_DELAY_IN_MINS = (() => {
|
||||
const envValue = process.env?.APP_CONSOLIDATION_DELAY_IN_MINS;
|
||||
const parsedValue = envValue ? parseInt(envValue, 10) : NaN;
|
||||
return isNaN(parsedValue) ? 3 : Math.max(1, parsedValue); // Default to 3, ensure at least 1
|
||||
})();
|
||||
|
||||
// Base time-related constant (in milliseconds) / DO NOT TOUCH
|
||||
const APP_CONSOLIDATION_DELAY = APP_CONSOLIDATION_DELAY_IN_MINS * 60000; // 1 minute (base timeout)
|
||||
|
||||
// Derived time-related constants based on APP_CONSOLIDATION_DELAY / DO NOT TOUCH
|
||||
const NOTIFICATION_STORAGE_EXPIRATION = APP_CONSOLIDATION_DELAY * 1.5; // 1.5 minutes (90s)
|
||||
const CONSOLIDATION_FLAG_EXPIRATION = APP_CONSOLIDATION_DELAY * 1.5; // 1.5 minutes (90s)
|
||||
const LOCK_EXPIRATION = APP_CONSOLIDATION_DELAY * 0.25; // 15 seconds (quarter of base)
|
||||
const RATE_LIMITER_DURATION = APP_CONSOLIDATION_DELAY * 0.1; // 6 seconds (tenth of base)
|
||||
|
||||
let addQueue;
|
||||
let consolidateQueue;
|
||||
|
||||
/**
|
||||
* Builds the scenario_text, fcm_text, and scenario_meta for a batch of notifications.
|
||||
*
|
||||
* @param {Array<Object>} notifications - Array of notification objects with 'body' and 'variables'.
|
||||
* @returns {Object} An object with 'scenario_text', 'fcm_text', and 'scenario_meta'.
|
||||
*/
|
||||
const buildNotificationContent = (notifications) => {
|
||||
const scenarioText = notifications.map((n) => n.body); // Array of text entries
|
||||
const fcmText = scenarioText.join(". "); // Concatenated text with period separator
|
||||
const scenarioMeta = notifications.map((n) => n.variables || {}); // Array of metadata objects
|
||||
|
||||
return {
|
||||
scenario_text: scenarioText,
|
||||
fcm_text: fcmText ? `${fcmText}.` : null, // Add trailing period if non-empty, otherwise null
|
||||
scenario_meta: scenarioMeta
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes the notification queues and workers for adding and consolidating notifications.
|
||||
*/
|
||||
const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
|
||||
if (!addQueue || !consolidateQueue) {
|
||||
logger.logger.debug("Initializing Notifications Queues");
|
||||
|
||||
addQueue = new Queue("notificationsAdd", {
|
||||
connection: pubClient,
|
||||
prefix: "{BULLMQ}",
|
||||
defaultJobOptions: { removeOnComplete: true, removeOnFail: true }
|
||||
});
|
||||
|
||||
consolidateQueue = new Queue("notificationsConsolidate", {
|
||||
connection: pubClient,
|
||||
prefix: "{BULLMQ}",
|
||||
defaultJobOptions: { removeOnComplete: true, removeOnFail: true }
|
||||
});
|
||||
|
||||
const addWorker = new Worker(
|
||||
"notificationsAdd",
|
||||
async (job) => {
|
||||
const { jobId, key, variables, recipients, body, jobRoNumber } = job.data;
|
||||
logger.logger.debug(`Adding notifications for jobId ${jobId}`);
|
||||
|
||||
const redisKeyPrefix = `app:notifications:${jobId}`;
|
||||
const notification = { key, variables, body, jobRoNumber, timestamp: Date.now() };
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const { user } = recipient;
|
||||
const userKey = `${redisKeyPrefix}:${user}`;
|
||||
const existingNotifications = await pubClient.get(userKey);
|
||||
const notifications = existingNotifications ? JSON.parse(existingNotifications) : [];
|
||||
notifications.push(notification);
|
||||
await pubClient.set(userKey, JSON.stringify(notifications), "EX", NOTIFICATION_STORAGE_EXPIRATION / 1000);
|
||||
logger.logger.debug(`Stored notification for ${user} under ${userKey}: ${JSON.stringify(notifications)}`);
|
||||
}
|
||||
|
||||
const consolidateKey = `app:consolidate:${jobId}`;
|
||||
const flagSet = await pubClient.setnx(consolidateKey, "pending");
|
||||
logger.logger.debug(`Consolidation flag set for jobId ${jobId}: ${flagSet}`);
|
||||
|
||||
if (flagSet) {
|
||||
await consolidateQueue.add(
|
||||
"consolidate-notifications",
|
||||
{ jobId, recipients },
|
||||
{
|
||||
jobId: `consolidate:${jobId}`,
|
||||
delay: APP_CONSOLIDATION_DELAY,
|
||||
attempts: 3,
|
||||
backoff: LOCK_EXPIRATION
|
||||
}
|
||||
);
|
||||
logger.logger.debug(`Scheduled consolidation for jobId ${jobId}`);
|
||||
await pubClient.expire(consolidateKey, CONSOLIDATION_FLAG_EXPIRATION / 1000);
|
||||
} else {
|
||||
logger.logger.debug(`Consolidation already scheduled for jobId ${jobId}`);
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: pubClient,
|
||||
prefix: "{BULLMQ}",
|
||||
concurrency: 5
|
||||
}
|
||||
);
|
||||
|
||||
const consolidateWorker = new Worker(
|
||||
"notificationsConsolidate",
|
||||
async (job) => {
|
||||
const { jobId, recipients } = job.data;
|
||||
logger.logger.debug(`Consolidating notifications for jobId ${jobId}`);
|
||||
|
||||
const redisKeyPrefix = `app:notifications:${jobId}`;
|
||||
const lockKey = `lock:consolidate:${jobId}`;
|
||||
const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", LOCK_EXPIRATION / 1000);
|
||||
logger.logger.debug(`Lock acquisition for jobId ${jobId}: ${lockAcquired}`);
|
||||
|
||||
if (lockAcquired) {
|
||||
try {
|
||||
const allNotifications = {};
|
||||
const uniqueUsers = [...new Set(recipients.map((r) => r.user))];
|
||||
logger.logger.debug(`Unique users for jobId ${jobId}: ${uniqueUsers}`);
|
||||
|
||||
for (const user of uniqueUsers) {
|
||||
const userKey = `${redisKeyPrefix}:${user}`;
|
||||
const notifications = await pubClient.get(userKey);
|
||||
logger.logger.debug(`Retrieved notifications for ${user}: ${notifications}`);
|
||||
|
||||
if (notifications) {
|
||||
const parsedNotifications = JSON.parse(notifications);
|
||||
const userRecipients = recipients.filter((r) => r.user === user);
|
||||
for (const { bodyShopId } of userRecipients) {
|
||||
allNotifications[user] = allNotifications[user] || {};
|
||||
allNotifications[user][bodyShopId] = parsedNotifications;
|
||||
}
|
||||
await pubClient.del(userKey);
|
||||
logger.logger.debug(`Deleted Redis key ${userKey}`);
|
||||
} else {
|
||||
logger.logger.debug(`No notifications found for ${user} under ${userKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.logger.debug(`Consolidated notifications: ${JSON.stringify(allNotifications)}`);
|
||||
|
||||
// Insert notifications into the database and collect IDs
|
||||
const notificationInserts = [];
|
||||
const notificationIdMap = new Map();
|
||||
|
||||
for (const [user, bodyShopData] of Object.entries(allNotifications)) {
|
||||
const userRecipients = recipients.filter((r) => r.user === user);
|
||||
const associationId = userRecipients[0]?.associationId;
|
||||
|
||||
for (const [bodyShopId, notifications] of Object.entries(bodyShopData)) {
|
||||
const { scenario_text, fcm_text, scenario_meta } = buildNotificationContent(notifications);
|
||||
notificationInserts.push({
|
||||
jobid: jobId,
|
||||
associationid: associationId,
|
||||
scenario_text: JSON.stringify(scenario_text),
|
||||
fcm_text: fcm_text,
|
||||
scenario_meta: JSON.stringify(scenario_meta)
|
||||
});
|
||||
notificationIdMap.set(`${user}:${bodyShopId}`, null);
|
||||
}
|
||||
}
|
||||
|
||||
if (notificationInserts.length > 0) {
|
||||
const insertResponse = await graphQLClient.request(INSERT_NOTIFICATIONS_MUTATION, {
|
||||
objects: notificationInserts
|
||||
});
|
||||
logger.logger.debug(
|
||||
`Inserted ${insertResponse.insert_notifications.affected_rows} notifications for jobId ${jobId}`
|
||||
);
|
||||
|
||||
insertResponse.insert_notifications.returning.forEach((row, index) => {
|
||||
const user = uniqueUsers[Math.floor(index / Object.keys(allNotifications[uniqueUsers[0]]).length)];
|
||||
const bodyShopId = Object.keys(allNotifications[user])[
|
||||
index % Object.keys(allNotifications[user]).length
|
||||
];
|
||||
notificationIdMap.set(`${user}:${bodyShopId}`, row.id);
|
||||
});
|
||||
}
|
||||
|
||||
// Emit notifications to users via Socket.io with notification ID
|
||||
for (const [user, bodyShopData] of Object.entries(allNotifications)) {
|
||||
const userMapping = await redisHelpers.getUserSocketMapping(user);
|
||||
const userRecipients = recipients.filter((r) => r.user === user);
|
||||
const associationId = userRecipients[0]?.associationId;
|
||||
|
||||
for (const [bodyShopId, notifications] of Object.entries(bodyShopData)) {
|
||||
const notificationId = notificationIdMap.get(`${user}:${bodyShopId}`);
|
||||
const jobRoNumber = notifications[0]?.jobRoNumber;
|
||||
|
||||
if (userMapping && userMapping[bodyShopId]?.socketIds) {
|
||||
userMapping[bodyShopId].socketIds.forEach((socketId) => {
|
||||
ioRedis.to(socketId).emit("notification", {
|
||||
jobId,
|
||||
jobRoNumber,
|
||||
bodyShopId,
|
||||
notifications,
|
||||
notificationId,
|
||||
associationId
|
||||
});
|
||||
});
|
||||
logger.logger.debug(
|
||||
`Sent ${notifications.length} consolidated notifications to ${user} for jobId ${jobId} with notificationId ${notificationId}`
|
||||
);
|
||||
} else {
|
||||
logger.logger.debug(`No socket IDs found for ${user} in bodyShopId ${bodyShopId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await pubClient.del(`app:consolidate:${jobId}`);
|
||||
} catch (err) {
|
||||
logger.log(`app-queue-consolidation-error`, "ERROR", "notifications", "api", {
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
await pubClient.del(lockKey);
|
||||
}
|
||||
} else {
|
||||
logger.logger.debug(`Skipped consolidation for jobId ${jobId} - lock held by another worker`);
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: pubClient,
|
||||
prefix: "{BULLMQ}",
|
||||
concurrency: 1,
|
||||
limiter: { max: 1, duration: RATE_LIMITER_DURATION }
|
||||
}
|
||||
);
|
||||
|
||||
addWorker.on("completed", (job) => logger.logger.debug(`Add job ${job.id} completed`));
|
||||
consolidateWorker.on("completed", (job) => logger.logger.debug(`Consolidate job ${job.id} completed`));
|
||||
addWorker.on("failed", (job, err) =>
|
||||
logger.log(`app-queue-notification-error`, "ERROR", "notifications", "api", {
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
})
|
||||
);
|
||||
consolidateWorker.on("failed", (job, err) =>
|
||||
logger.log(`app-queue-consolidation-failed:`, "ERROR", "notifications", "api", {
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
})
|
||||
);
|
||||
|
||||
// Register cleanup task instead of direct process listeners
|
||||
const shutdown = async () => {
|
||||
logger.logger.debug("Closing app queue workers...");
|
||||
await Promise.all([addWorker.close(), consolidateWorker.close()]);
|
||||
logger.logger.debug("App queue workers closed");
|
||||
};
|
||||
|
||||
registerCleanupTask(shutdown);
|
||||
}
|
||||
|
||||
return addQueue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the initialized `addQueue` instance.
|
||||
*/
|
||||
const getQueue = () => {
|
||||
if (!addQueue) throw new Error("Add queue not initialized. Ensure loadAppQueue is called during bootstrap.");
|
||||
return addQueue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatches notifications to the `addQueue` for processing.
|
||||
*/
|
||||
const dispatchAppsToQueue = async ({ appsToDispatch, logger }) => {
|
||||
const appQueue = getQueue();
|
||||
|
||||
for (const app of appsToDispatch) {
|
||||
const { jobId, bodyShopId, key, variables, recipients, body, jobRoNumber } = app;
|
||||
await appQueue.add(
|
||||
"add-notification",
|
||||
{ jobId, bodyShopId, key, variables, recipients, body, jobRoNumber },
|
||||
{ jobId: `${jobId}:${Date.now()}` }
|
||||
);
|
||||
logger.logger.debug(`Added notification to queue for jobId ${jobId} with ${recipients.length} recipients`);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { loadAppQueue, getQueue, dispatchAppsToQueue };
|
||||
241
server/notifications/queues/emailQueue.js
Normal file
241
server/notifications/queues/emailQueue.js
Normal file
@@ -0,0 +1,241 @@
|
||||
const { Queue, Worker } = require("bullmq");
|
||||
const { sendTaskEmail } = require("../../email/sendemail");
|
||||
const generateEmailTemplate = require("../../email/generateTemplate");
|
||||
const { InstanceEndpoints } = require("../../utils/instanceMgr");
|
||||
const { registerCleanupTask } = require("../../utils/cleanupManager");
|
||||
|
||||
const EMAIL_CONSOLIDATION_DELAY_IN_MINS = (() => {
|
||||
const envValue = process.env?.EMAIL_CONSOLIDATION_DELAY_IN_MINS;
|
||||
const parsedValue = envValue ? parseInt(envValue, 10) : NaN;
|
||||
return isNaN(parsedValue) ? 3 : Math.max(1, parsedValue); // Default to 3, ensure at least 1
|
||||
})();
|
||||
|
||||
// Base time-related constant (in milliseconds) / DO NOT TOUCH
|
||||
const EMAIL_CONSOLIDATION_DELAY = EMAIL_CONSOLIDATION_DELAY_IN_MINS * 60000; // 1 minute (base timeout)
|
||||
|
||||
// Derived time-related constants based on EMAIL_CONSOLIDATION_DELAY / DO NOT TOUCH, these are pegged to EMAIL_CONSOLIDATION_DELAY
|
||||
const CONSOLIDATION_KEY_EXPIRATION = EMAIL_CONSOLIDATION_DELAY * 1.5; // 1.5 minutes (90s, buffer for consolidation)
|
||||
const LOCK_EXPIRATION = EMAIL_CONSOLIDATION_DELAY * 0.25; // 15 seconds (quarter of base, for lock duration)
|
||||
const RATE_LIMITER_DURATION = EMAIL_CONSOLIDATION_DELAY * 0.1; // 6 seconds (tenth of base, for rate limiting)
|
||||
const NOTIFICATION_EXPIRATION = EMAIL_CONSOLIDATION_DELAY * 1.5; // 1.5 minutes (matches consolidation key expiration)
|
||||
|
||||
let emailAddQueue;
|
||||
let emailConsolidateQueue;
|
||||
let emailAddWorker;
|
||||
let emailConsolidateWorker;
|
||||
|
||||
/**
|
||||
* Initializes the email notification queues and workers.
|
||||
*
|
||||
* @param {Object} options - Configuration options for queue initialization.
|
||||
* @param {Object} options.pubClient - Redis client instance for queue communication.
|
||||
* @param {Object} options.logger - Logger instance for logging events and debugging.
|
||||
* @returns {Queue} The initialized `emailAddQueue` instance for dispatching notifications.
|
||||
*/
|
||||
const loadEmailQueue = async ({ pubClient, logger }) => {
|
||||
if (!emailAddQueue || !emailConsolidateQueue) {
|
||||
logger.logger.debug("Initializing Email Notification Queues");
|
||||
|
||||
// Queue for adding email notifications
|
||||
emailAddQueue = new Queue("emailAdd", {
|
||||
connection: pubClient,
|
||||
prefix: "{BULLMQ}",
|
||||
defaultJobOptions: { removeOnComplete: true, removeOnFail: true }
|
||||
});
|
||||
|
||||
// Queue for consolidating and sending emails
|
||||
emailConsolidateQueue = new Queue("emailConsolidate", {
|
||||
connection: pubClient,
|
||||
prefix: "{BULLMQ}",
|
||||
defaultJobOptions: { removeOnComplete: true, removeOnFail: true }
|
||||
});
|
||||
|
||||
// Worker to process adding notifications
|
||||
emailAddWorker = new Worker(
|
||||
"emailAdd",
|
||||
async (job) => {
|
||||
const { jobId, jobRoNumber, bodyShopName, body, recipients } = job.data;
|
||||
logger.logger.debug(`Adding email notifications for jobId ${jobId}`);
|
||||
|
||||
const redisKeyPrefix = `email:notifications:${jobId}`;
|
||||
for (const recipient of recipients) {
|
||||
const { user, firstName, lastName } = recipient;
|
||||
const userKey = `${redisKeyPrefix}:${user}`;
|
||||
await pubClient.rpush(userKey, body);
|
||||
await pubClient.expire(userKey, NOTIFICATION_EXPIRATION / 1000);
|
||||
const detailsKey = `email:recipientDetails:${jobId}:${user}`;
|
||||
await pubClient.hsetnx(detailsKey, "firstName", firstName || "");
|
||||
await pubClient.hsetnx(detailsKey, "lastName", lastName || "");
|
||||
await pubClient.expire(detailsKey, NOTIFICATION_EXPIRATION / 1000);
|
||||
await pubClient.sadd(`email:recipients:${jobId}`, user);
|
||||
logger.logger.debug(`Stored message for ${user} under ${userKey}: ${body}`);
|
||||
}
|
||||
|
||||
const consolidateKey = `email:consolidate:${jobId}`;
|
||||
const flagSet = await pubClient.setnx(consolidateKey, "pending");
|
||||
if (flagSet) {
|
||||
await emailConsolidateQueue.add(
|
||||
"consolidate-emails",
|
||||
{ jobId, jobRoNumber, bodyShopName },
|
||||
{
|
||||
jobId: `consolidate:${jobId}`,
|
||||
delay: EMAIL_CONSOLIDATION_DELAY,
|
||||
attempts: 3,
|
||||
backoff: LOCK_EXPIRATION
|
||||
}
|
||||
);
|
||||
logger.logger.debug(`Scheduled email consolidation for jobId ${jobId}`);
|
||||
await pubClient.expire(consolidateKey, CONSOLIDATION_KEY_EXPIRATION / 1000);
|
||||
} else {
|
||||
logger.logger.debug(`Email consolidation already scheduled for jobId ${jobId}`);
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: pubClient,
|
||||
prefix: "{BULLMQ}",
|
||||
concurrency: 5
|
||||
}
|
||||
);
|
||||
|
||||
// Worker to consolidate and send emails
|
||||
emailConsolidateWorker = new Worker(
|
||||
"emailConsolidate",
|
||||
async (job) => {
|
||||
const { jobId, jobRoNumber, bodyShopName } = job.data;
|
||||
logger.logger.debug(`Consolidating emails for jobId ${jobId}`);
|
||||
|
||||
const lockKey = `lock:emailConsolidate:${jobId}`;
|
||||
const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", LOCK_EXPIRATION / 1000);
|
||||
if (lockAcquired) {
|
||||
try {
|
||||
const recipientsSet = `email:recipients:${jobId}`;
|
||||
const recipients = await pubClient.smembers(recipientsSet);
|
||||
for (const recipient of recipients) {
|
||||
const userKey = `email:notifications:${jobId}:${recipient}`;
|
||||
const detailsKey = `email:recipientDetails:${jobId}:${recipient}`;
|
||||
const messages = await pubClient.lrange(userKey, 0, -1);
|
||||
if (messages.length > 0) {
|
||||
const details = await pubClient.hgetall(detailsKey);
|
||||
const firstName = details.firstName || "User";
|
||||
const multipleUpdateString = messages.length > 1 ? "Updates" : "Update";
|
||||
const subject = `${multipleUpdateString} for job ${jobRoNumber || "N/A"} at ${bodyShopName}`;
|
||||
const emailBody = generateEmailTemplate({
|
||||
header: `${multipleUpdateString} for Job ${jobRoNumber || "N/A"}`,
|
||||
subHeader: `Dear ${firstName},`,
|
||||
body: `
|
||||
<p>There have been updates to job ${jobRoNumber || "N/A"} at ${bodyShopName}:</p><br/>
|
||||
<ul>
|
||||
${messages.map((msg) => `<li>${msg}</li>`).join("")}
|
||||
</ul><br/><br/>
|
||||
<p><a href="${InstanceEndpoints()}/manage/jobs/${jobId}">Please check the job for more details.</a></p>
|
||||
`
|
||||
});
|
||||
await sendTaskEmail({
|
||||
to: recipient,
|
||||
subject,
|
||||
type: "html",
|
||||
html: emailBody
|
||||
});
|
||||
logger.logger.debug(
|
||||
`Sent consolidated email to ${recipient} for jobId ${jobId} with ${messages.length} updates`
|
||||
);
|
||||
await pubClient.del(userKey);
|
||||
await pubClient.del(detailsKey);
|
||||
}
|
||||
}
|
||||
await pubClient.del(recipientsSet);
|
||||
await pubClient.del(`email:consolidate:${jobId}`);
|
||||
} catch (err) {
|
||||
logger.log(`email-queue-consolidation-error`, "ERROR", "notifications", "api", {
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
await pubClient.del(lockKey);
|
||||
}
|
||||
} else {
|
||||
logger.logger.debug(`Skipped email consolidation for jobId ${jobId} - lock held by another worker`);
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: pubClient,
|
||||
prefix: "{BULLMQ}",
|
||||
concurrency: 1,
|
||||
limiter: { max: 1, duration: RATE_LIMITER_DURATION }
|
||||
}
|
||||
);
|
||||
|
||||
// Event handlers for workers
|
||||
emailAddWorker.on("completed", (job) => logger.logger.debug(`Email add job ${job.id} completed`));
|
||||
emailConsolidateWorker.on("completed", (job) => logger.logger.debug(`Email consolidate job ${job.id} completed`));
|
||||
emailAddWorker.on("failed", (job, err) =>
|
||||
logger.log(`add-email-queue-failed`, "ERROR", "notifications", "api", {
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
})
|
||||
);
|
||||
emailConsolidateWorker.on("failed", (job, err) =>
|
||||
logger.log(`email-consolidation-job-failed`, "ERROR", "notifications", "api", {
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
})
|
||||
);
|
||||
|
||||
// Register cleanup task instead of direct process listeners
|
||||
const shutdown = async () => {
|
||||
logger.logger.debug("Closing email queue workers...");
|
||||
await Promise.all([emailAddWorker.close(), emailConsolidateWorker.close()]);
|
||||
logger.logger.debug("Email queue workers closed");
|
||||
};
|
||||
registerCleanupTask(shutdown);
|
||||
}
|
||||
|
||||
return emailAddQueue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the initialized `emailAddQueue` instance.
|
||||
*
|
||||
* @returns {Queue} The `emailAddQueue` instance for adding notifications.
|
||||
* @throws {Error} If `emailAddQueue` is not initialized.
|
||||
*/
|
||||
const getQueue = () => {
|
||||
if (!emailAddQueue) {
|
||||
throw new Error("Email add queue not initialized. Ensure loadEmailQueue is called during bootstrap.");
|
||||
}
|
||||
return emailAddQueue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatches email notifications to the `emailAddQueue` for processing.
|
||||
*
|
||||
* @param {Object} options - Options for dispatching notifications.
|
||||
* @param {Array} options.emailsToDispatch - Array of email notification objects.
|
||||
* @param {Object} options.logger - Logger instance for logging dispatch events.
|
||||
* @returns {Promise<void>} Resolves when all notifications are added to the queue.
|
||||
*/
|
||||
const dispatchEmailsToQueue = async ({ emailsToDispatch, logger }) => {
|
||||
const emailAddQueue = getQueue();
|
||||
|
||||
for (const email of emailsToDispatch) {
|
||||
const { jobId, jobRoNumber, bodyShopName, body, recipients } = email;
|
||||
|
||||
if (!jobId || !jobRoNumber || !bodyShopName || !body || !recipients.length) {
|
||||
logger.logger.warn(
|
||||
`Skipping email dispatch for jobId ${jobId} due to missing data: ` +
|
||||
`jobRoNumber=${jobRoNumber || "N/A"}, bodyShopName=${bodyShopName}, body=${body}, recipients=${recipients.length}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await emailAddQueue.add(
|
||||
"add-email-notification",
|
||||
{ jobId, jobRoNumber, bodyShopName, body, recipients },
|
||||
{ jobId: `${jobId}:${Date.now()}` }
|
||||
);
|
||||
logger.logger.debug(`Added email notification to queue for jobId ${jobId} with ${recipients.length} recipients`);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { loadEmailQueue, getQueue, dispatchEmailsToQueue };
|
||||
691
server/notifications/scenarioBuilders.js
Normal file
691
server/notifications/scenarioBuilders.js
Normal file
@@ -0,0 +1,691 @@
|
||||
const { getJobAssignmentType, formatTaskPriority } = require("./stringHelpers");
|
||||
const moment = require("moment-timezone");
|
||||
const { startCase } = require("lodash");
|
||||
const Dinero = require("dinero.js");
|
||||
|
||||
Dinero.globalRoundingMode = "HALF_EVEN";
|
||||
|
||||
/**
|
||||
* Populates the recipients for app, email, and FCM notifications based on scenario watchers.
|
||||
*
|
||||
* @param {Object} data - The data object containing scenarioWatchers and bodyShopId.
|
||||
* @param {Object} result - The result object to populate with recipients for app, email, and FCM notifications.
|
||||
*/
|
||||
const populateWatchers = (data, result) => {
|
||||
data.scenarioWatchers.forEach((recipients) => {
|
||||
const { user, app, fcm, email, firstName, lastName, employeeId, associationId } = recipients;
|
||||
if (app === true)
|
||||
result.app.recipients.push({
|
||||
user,
|
||||
bodyShopId: data.bodyShopId,
|
||||
employeeId,
|
||||
associationId
|
||||
});
|
||||
if (fcm === true) result.fcm.recipients.push(user);
|
||||
if (email === true) result.email.recipients.push({ user, firstName, lastName });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds notification data for changes to alternate transport.
|
||||
*/
|
||||
const alternateTransportChangedBuilder = (data) => {
|
||||
const body = `The alternate transportation has been changed from ${data.changedFields.alt_transport?.old || "unset"} to ${data?.changedFields?.alt_transport?.new || "unset"}.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
bodyShopId: data.bodyShopId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
key: "notifications.job.alternateTransportChanged",
|
||||
body,
|
||||
variables: {
|
||||
alternateTransport: data?.changedFields?.alt_transport?.new,
|
||||
oldAlternateTransport: data?.changedFields?.alt_transport?.old
|
||||
},
|
||||
recipients: []
|
||||
},
|
||||
email: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopName: data.bodyShopName,
|
||||
body,
|
||||
recipients: []
|
||||
},
|
||||
fcm: { recipients: [] }
|
||||
};
|
||||
|
||||
populateWatchers(data, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds notification data for bill posted events.
|
||||
*/
|
||||
const billPostedHandler = (data) => {
|
||||
const facing = data?.data?.isinhouse ? "in-house" : "vendor";
|
||||
const body = `An ${facing} ${data?.data?.is_credit_memo ? "credit memo" : "bill"} has been posted.`.trim();
|
||||
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.billPosted",
|
||||
body,
|
||||
variables: {
|
||||
isInHouse: data?.data?.isinhouse,
|
||||
isCreditMemo: data?.data?.is_credit_memo
|
||||
},
|
||||
recipients: []
|
||||
},
|
||||
email: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopName: data.bodyShopName,
|
||||
body,
|
||||
recipients: []
|
||||
},
|
||||
fcm: { recipients: [] }
|
||||
};
|
||||
|
||||
populateWatchers(data, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds notification data for changes to critical parts status.
|
||||
*/
|
||||
//
|
||||
const criticalPartsStatusChangedBuilder = (data) => {
|
||||
const body = `The status on a critical part line (${data?.data?.line_desc}) has changed to ${data?.data?.status || "unset"}.`;
|
||||
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
bodyShopId: data.bodyShopId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
key: "notifications.job.criticalPartsStatusChanged",
|
||||
body,
|
||||
variables: {
|
||||
joblineId: data?.data?.id, // If we want to deeplink to the jobline
|
||||
status: data?.data?.status,
|
||||
line_desc: data?.data?.line_desc
|
||||
},
|
||||
recipients: []
|
||||
},
|
||||
email: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopName: data.bodyShopName,
|
||||
body,
|
||||
recipients: []
|
||||
},
|
||||
fcm: { recipients: [] }
|
||||
};
|
||||
|
||||
populateWatchers(data, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds notification data for completed intake or delivery checklists.
|
||||
*/
|
||||
const intakeDeliveryChecklistCompletedBuilder = (data) => {
|
||||
const checklistType = data?.changedFields?.intakechecklist ? "intake" : "delivery";
|
||||
const body = `The ${checklistType.charAt(0).toUpperCase() + checklistType.slice(1)} checklist has been completed.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.checklistCompleted",
|
||||
body,
|
||||
variables: {
|
||||
checklistType,
|
||||
completed: true
|
||||
},
|
||||
recipients: []
|
||||
},
|
||||
email: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopName: data.bodyShopName,
|
||||
body,
|
||||
recipients: []
|
||||
},
|
||||
fcm: { recipients: [] }
|
||||
};
|
||||
|
||||
populateWatchers(data, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds notification data for job assignment events.
|
||||
*/
|
||||
const jobAssignedToMeBuilder = (data) => {
|
||||
const body = `You have been assigned to ${getJobAssignmentType(data.scenarioFields?.[0])}.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.assigned",
|
||||
body,
|
||||
variables: {
|
||||
type: data.scenarioFields?.[0]
|
||||
},
|
||||
recipients: []
|
||||
},
|
||||
email: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopName: data.bodyShopName,
|
||||
body,
|
||||
recipients: []
|
||||
},
|
||||
fcm: { recipients: [] }
|
||||
};
|
||||
|
||||
populateWatchers(data, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds notification data for jobs added to production.
|
||||
*/
|
||||
const jobsAddedToProductionBuilder = (data) => {
|
||||
const body = `Job is now in production.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.addedToProduction",
|
||||
body,
|
||||
variables: {},
|
||||
recipients: []
|
||||
},
|
||||
email: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopName: data.bodyShopName,
|
||||
body,
|
||||
recipients: []
|
||||
},
|
||||
fcm: { recipients: [] }
|
||||
};
|
||||
|
||||
populateWatchers(data, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds notification data for job status changes.
|
||||
*/
|
||||
const jobStatusChangeBuilder = (data) => {
|
||||
const body = `The status has changed from ${data?.changedFields?.status?.old || "unset"} to ${data?.changedFields?.status?.new || "unset"}`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.statusChanged",
|
||||
body,
|
||||
variables: {
|
||||
status: data.changedFields.status.new,
|
||||
oldStatus: data.changedFields.status.old
|
||||
},
|
||||
recipients: []
|
||||
},
|
||||
email: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopName: data.bodyShopName,
|
||||
body,
|
||||
recipients: []
|
||||
},
|
||||
fcm: { recipients: [] }
|
||||
};
|
||||
|
||||
populateWatchers(data, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds notification data for new media added or reassigned events.
|
||||
*/
|
||||
const newMediaAddedReassignedBuilder = (data) => {
|
||||
// Determine if it's an image or document
|
||||
const mediaType = data?.data?.type?.startsWith("image") ? "Image" : "Document";
|
||||
|
||||
// Determine the action
|
||||
let action;
|
||||
|
||||
if (data?.data?._documentMoved) {
|
||||
action = "moved to another job"; // Special case for document moved from this job
|
||||
} else if (data.isNew) {
|
||||
action = "added"; // New media
|
||||
} else if (data.changedFields?.jobid && data.changedFields.jobid.old !== data.changedFields.jobid.new) {
|
||||
action = "moved to this job";
|
||||
} else {
|
||||
action = "updated";
|
||||
}
|
||||
|
||||
// Construct the body string
|
||||
const body = `An ${mediaType} has been ${action}.`;
|
||||
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.newMediaAdded",
|
||||
body,
|
||||
variables: {
|
||||
mediaType,
|
||||
action,
|
||||
movedToJob: data?.data?._movedToJob
|
||||
},
|
||||
recipients: []
|
||||
},
|
||||
email: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopName: data.bodyShopName,
|
||||
body,
|
||||
recipients: []
|
||||
},
|
||||
fcm: { recipients: [] }
|
||||
};
|
||||
|
||||
populateWatchers(data, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds notification data for new notes added to a job.
|
||||
*/
|
||||
const newNoteAddedBuilder = (data) => {
|
||||
const body = [
|
||||
"A",
|
||||
data?.data?.critical && "critical",
|
||||
data?.data?.private && "private",
|
||||
data?.data?.type,
|
||||
"note has been added by",
|
||||
`${data.data.created_by}`
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.newNoteAdded",
|
||||
body,
|
||||
variables: {
|
||||
createdBy: data?.data?.created_by,
|
||||
critical: data?.data?.critical,
|
||||
type: data?.data?.type,
|
||||
private: data?.data?.private
|
||||
},
|
||||
recipients: []
|
||||
},
|
||||
email: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopName: data.bodyShopName,
|
||||
body,
|
||||
recipients: []
|
||||
},
|
||||
fcm: { recipients: [] }
|
||||
};
|
||||
|
||||
populateWatchers(data, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds notification data for new time tickets posted.
|
||||
*/
|
||||
const newTimeTicketPostedBuilder = (data) => {
|
||||
const type = data?.data?.cost_center;
|
||||
const body = `A ${startCase(type.toLowerCase())} time ticket for ${data?.data?.date} has been posted.`.trim();
|
||||
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.newTimeTicketPosted",
|
||||
body,
|
||||
variables: {
|
||||
type,
|
||||
date: data?.data?.date
|
||||
},
|
||||
recipients: []
|
||||
},
|
||||
email: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopName: data.bodyShopName,
|
||||
body,
|
||||
recipients: []
|
||||
},
|
||||
fcm: { recipients: [] }
|
||||
};
|
||||
|
||||
populateWatchers(data, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds notification data for parts marked as back-ordered.
|
||||
*/
|
||||
const partMarkedBackOrderedBuilder = (data) => {
|
||||
const body = `A part ${data?.data?.line_desc} has been marked as back-ordered.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.partBackOrdered",
|
||||
body,
|
||||
variables: {
|
||||
line_desc: data?.data?.line_desc
|
||||
},
|
||||
recipients: []
|
||||
},
|
||||
email: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopName: data.bodyShopName,
|
||||
body,
|
||||
recipients: []
|
||||
},
|
||||
fcm: { recipients: [] }
|
||||
};
|
||||
|
||||
populateWatchers(data, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds notification data for payment collection events.
|
||||
*/
|
||||
const paymentCollectedCompletedBuilder = (data) => {
|
||||
const momentFormat = "MM/DD/YYYY";
|
||||
|
||||
// Format amount using Dinero.js
|
||||
const amountDinero = Dinero({
|
||||
amount: Math.round((data.data.amount || 0) * 100) // Convert to cents, default to 0 if missing
|
||||
});
|
||||
|
||||
const amountFormatted = amountDinero.toFormat();
|
||||
|
||||
const payer = data.data.payer;
|
||||
const paymentType = data.data.type;
|
||||
const paymentDate = moment(data.data.date).format(momentFormat);
|
||||
|
||||
const body = `Payment of ${amountFormatted} has been collected from ${payer} via ${paymentType} on ${paymentDate}`;
|
||||
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.paymentCollected",
|
||||
body,
|
||||
variables: {
|
||||
amount: data.data.amount,
|
||||
payer: data.data.payer,
|
||||
type: data.data.type,
|
||||
date: data.data.date
|
||||
},
|
||||
recipients: []
|
||||
},
|
||||
email: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopName: data.bodyShopName,
|
||||
body,
|
||||
recipients: []
|
||||
},
|
||||
fcm: { recipients: [] }
|
||||
};
|
||||
|
||||
populateWatchers(data, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds notification data for changes to scheduled dates.
|
||||
*/
|
||||
const scheduledDatesChangedBuilder = (data) => {
|
||||
const changedFields = data.changedFields;
|
||||
|
||||
// Define field configurations
|
||||
const fieldConfigs = {
|
||||
scheduled_in: "Scheduled In",
|
||||
scheduled_completion: "Scheduled Completion",
|
||||
scheduled_delivery: "Scheduled Delivery"
|
||||
};
|
||||
|
||||
// Helper function to format date and time with "at"
|
||||
const formatDateTime = (date) => {
|
||||
if (!date) return "unset";
|
||||
const formatted = moment(date).tz(data.bodyShopTimezone);
|
||||
const datePart = formatted.format("MM/DD/YYYY");
|
||||
const timePart = formatted.format("hh:mm a");
|
||||
return `${datePart} at ${timePart}`;
|
||||
};
|
||||
|
||||
// Build field messages dynamically
|
||||
const fieldMessages = Object.entries(fieldConfigs)
|
||||
.filter(([field]) => changedFields[field]) // Only include changed fields
|
||||
.map(([field, label]) => {
|
||||
const { old, new: newValue } = changedFields[field];
|
||||
|
||||
// Case 1: Scheduled date cancelled (from value to null)
|
||||
if (old && !newValue) {
|
||||
return `${label} was cancelled (previously ${formatDateTime(old)}).`;
|
||||
}
|
||||
// Case 2: Scheduled date set (from null to value)
|
||||
else if (!old && newValue) {
|
||||
return `${label} was set to ${formatDateTime(newValue)}.`;
|
||||
}
|
||||
// Case 3: Scheduled date changed (from value to value)
|
||||
else if (old && newValue) {
|
||||
return `${label} changed from ${formatDateTime(old)} to ${formatDateTime(newValue)}.`;
|
||||
}
|
||||
return ""; // Fallback, though this shouldn't happen with the filter
|
||||
})
|
||||
.filter(Boolean); // Remove any empty strings
|
||||
|
||||
const body = fieldMessages.length > 0 ? fieldMessages.join(" ") : "Scheduled dates have been updated.";
|
||||
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.scheduledDatesChanged",
|
||||
body,
|
||||
variables: {
|
||||
scheduledIn: changedFields.scheduled_in?.new,
|
||||
oldScheduledIn: changedFields.scheduled_in?.old,
|
||||
scheduledCompletion: changedFields.scheduled_completion?.new,
|
||||
oldScheduledCompletion: changedFields.scheduled_completion?.old,
|
||||
scheduledDelivery: changedFields.scheduled_delivery?.new,
|
||||
oldScheduledDelivery: changedFields.scheduled_delivery?.old
|
||||
},
|
||||
recipients: []
|
||||
},
|
||||
email: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopName: data.bodyShopName,
|
||||
body,
|
||||
recipients: []
|
||||
},
|
||||
fcm: { recipients: [] }
|
||||
};
|
||||
|
||||
populateWatchers(data, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds notification data for tasks updated or created.
|
||||
*/
|
||||
const tasksUpdatedCreatedBuilder = (data) => {
|
||||
const momentFormat = "MM/DD/YYYY hh:mm a";
|
||||
const timezone = data.bodyShopTimezone;
|
||||
const taskTitle = data?.data?.title ? `"${data.data.title}"` : "Unnamed Task";
|
||||
|
||||
let body;
|
||||
let variables;
|
||||
|
||||
if (data.isNew) {
|
||||
// Created case
|
||||
const priority = formatTaskPriority(data?.data?.priority);
|
||||
const createdBy = data?.data?.created_by || "Unknown"; // Fallback for undefined created_by
|
||||
const dueDate = data.data.due_date ? ` due on ${moment(data.data.due_date).tz(timezone).format(momentFormat)}` : "";
|
||||
const completedOnCreation = data.data.completed === true;
|
||||
body = `A ${priority} task ${taskTitle} has been created${completedOnCreation ? " and marked completed" : ""} by ${createdBy}${dueDate}.`;
|
||||
variables = {
|
||||
isNew: data.isNew,
|
||||
roNumber: data.jobRoNumber,
|
||||
title: data?.data?.title,
|
||||
priority: data?.data?.priority,
|
||||
createdBy: data?.data?.created_by,
|
||||
dueDate: data?.data?.due_date,
|
||||
completed: completedOnCreation ? data?.data?.completed : undefined // Only include if true
|
||||
};
|
||||
} else {
|
||||
// Updated case
|
||||
const changedFields = data.changedFields;
|
||||
const fieldNames = Object.keys(changedFields);
|
||||
const oldTitle = changedFields.title ? `"${changedFields.title.old || "Unnamed Task"}"` : taskTitle;
|
||||
|
||||
// Special case: Only 'completed' changed
|
||||
if (fieldNames.length === 1 && changedFields.completed) {
|
||||
body = `Task ${oldTitle} was marked ${changedFields.completed.new ? "complete" : "incomplete"}`;
|
||||
variables = {
|
||||
isNew: data.isNew,
|
||||
roNumber: data.jobRoNumber,
|
||||
title: data?.data?.title,
|
||||
changedCompleted: data?.changedFields?.completed?.new
|
||||
};
|
||||
} else {
|
||||
// General update case
|
||||
const fieldMessages = [];
|
||||
|
||||
if (changedFields.title) {
|
||||
fieldMessages.push(`Task ${oldTitle} changed title to "${changedFields.title.new || "unnamed task"}".`);
|
||||
}
|
||||
if (changedFields.description) {
|
||||
fieldMessages.push("Description updated.");
|
||||
}
|
||||
if (changedFields.priority) {
|
||||
fieldMessages.push(`Priority changed to ${formatTaskPriority(changedFields.priority.new)}.`);
|
||||
}
|
||||
if (changedFields.due_date) {
|
||||
fieldMessages.push(`Due date set to ${moment(changedFields.due_date.new).tz(timezone).format(momentFormat)}.`);
|
||||
}
|
||||
if (changedFields.completed) {
|
||||
fieldMessages.push(`Status changed to ${changedFields.completed.new ? "complete" : "incomplete"}.`);
|
||||
}
|
||||
|
||||
body =
|
||||
fieldMessages.length > 0
|
||||
? fieldMessages.length === 1 && changedFields.title
|
||||
? fieldMessages[0] // If only title changed, use it standalone
|
||||
: `Task ${oldTitle} updated: ${fieldMessages.join(", ")}`
|
||||
: `Task ${oldTitle} has been updated.`;
|
||||
variables = {
|
||||
isNew: data.isNew,
|
||||
roNumber: data.jobRoNumber,
|
||||
title: data?.data?.title,
|
||||
changedTitleOld: data?.changedFields?.title?.old,
|
||||
changedTitleNew: data?.changedFields?.title?.new,
|
||||
changedPriority: data?.changedFields?.priority?.new,
|
||||
changedDueDate: data?.changedFields?.due_date?.new,
|
||||
changedCompleted: data?.changedFields?.completed?.new
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: data.isNew ? "notifications.job.taskCreated" : "notifications.job.taskUpdated",
|
||||
body,
|
||||
variables,
|
||||
recipients: []
|
||||
},
|
||||
email: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopName: data.bodyShopName,
|
||||
body,
|
||||
recipients: []
|
||||
},
|
||||
fcm: { recipients: [] }
|
||||
};
|
||||
|
||||
populateWatchers(data, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds notification data for supplement imported events.
|
||||
* TODO: This is an advanced case and will be done later
|
||||
*/
|
||||
const supplementImportedBuilder = (data) => {
|
||||
const body = `A supplement has been imported.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.supplementImported",
|
||||
body,
|
||||
variables: {},
|
||||
recipients: []
|
||||
},
|
||||
email: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopName: data.bodyShopName,
|
||||
body,
|
||||
recipients: []
|
||||
},
|
||||
fcm: { recipients: [] }
|
||||
};
|
||||
|
||||
populateWatchers(data, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
alternateTransportChangedBuilder,
|
||||
billPostedHandler,
|
||||
criticalPartsStatusChangedBuilder,
|
||||
intakeDeliveryChecklistCompletedBuilder,
|
||||
jobAssignedToMeBuilder,
|
||||
jobsAddedToProductionBuilder,
|
||||
jobStatusChangeBuilder,
|
||||
newMediaAddedReassignedBuilder,
|
||||
newNoteAddedBuilder,
|
||||
newTimeTicketPostedBuilder,
|
||||
partMarkedBackOrderedBuilder,
|
||||
paymentCollectedCompletedBuilder,
|
||||
scheduledDatesChangedBuilder,
|
||||
supplementImportedBuilder,
|
||||
tasksUpdatedCreatedBuilder
|
||||
};
|
||||
238
server/notifications/scenarioMapper.js
Normal file
238
server/notifications/scenarioMapper.js
Normal file
@@ -0,0 +1,238 @@
|
||||
const {
|
||||
jobAssignedToMeBuilder,
|
||||
billPostedHandler,
|
||||
newNoteAddedBuilder,
|
||||
scheduledDatesChangedBuilder,
|
||||
tasksUpdatedCreatedBuilder,
|
||||
jobStatusChangeBuilder,
|
||||
jobsAddedToProductionBuilder,
|
||||
alternateTransportChangedBuilder,
|
||||
newTimeTicketPostedBuilder,
|
||||
intakeDeliveryChecklistCompletedBuilder,
|
||||
paymentCollectedCompletedBuilder,
|
||||
newMediaAddedReassignedBuilder,
|
||||
criticalPartsStatusChangedBuilder,
|
||||
supplementImportedBuilder,
|
||||
partMarkedBackOrderedBuilder
|
||||
} = require("./scenarioBuilders");
|
||||
const logger = require("../utils/logger");
|
||||
const { isFunction } = require("lodash");
|
||||
|
||||
/**
|
||||
* An array of notification scenario definitions.
|
||||
*
|
||||
* Each scenario object can include the following properties:
|
||||
* - key {string}: The unique scenario name.
|
||||
* - table {string}: The table name to check for changes.
|
||||
* - fields {Array<string>}: Fields to check for changes.
|
||||
* - matchToUserFields {Array<string>}: Fields used to match scenarios to user data.
|
||||
* - onNew {boolean|Array<boolean>}: Indicates whether the scenario should be triggered on new data.
|
||||
* - builder {Function}: A function to handle the scenario.
|
||||
* - onlyTruthyValues {boolean|Array<string>}: Specifies fields that must have truthy values for the scenario to match.
|
||||
* - filterCallback {Function}: Optional callback (sync or async) to further filter the scenario based on event data (returns boolean).
|
||||
*/
|
||||
const notificationScenarios = [
|
||||
{
|
||||
key: "job-assigned-to-me",
|
||||
table: "jobs",
|
||||
fields: ["employee_prep", "employee_body", "employee_csr", "employee_refinish"],
|
||||
matchToUserFields: ["employee_prep", "employee_body", "employee_csr", "employee_refinish"],
|
||||
builder: jobAssignedToMeBuilder
|
||||
},
|
||||
{
|
||||
key: "bill-posted",
|
||||
table: "bills",
|
||||
builder: billPostedHandler,
|
||||
onNew: true
|
||||
},
|
||||
{
|
||||
key: "new-note-added",
|
||||
table: "notes",
|
||||
builder: newNoteAddedBuilder,
|
||||
onNew: true
|
||||
},
|
||||
{
|
||||
key: "schedule-dates-changed",
|
||||
table: "jobs",
|
||||
fields: ["scheduled_in", "scheduled_completion", "scheduled_delivery"],
|
||||
builder: scheduledDatesChangedBuilder
|
||||
},
|
||||
{
|
||||
key: "tasks-updated-created",
|
||||
table: "tasks",
|
||||
fields: ["updated_at"],
|
||||
// onNew: true,
|
||||
builder: tasksUpdatedCreatedBuilder
|
||||
},
|
||||
{
|
||||
key: "job-status-change",
|
||||
table: "jobs",
|
||||
fields: ["status"],
|
||||
builder: jobStatusChangeBuilder
|
||||
},
|
||||
{
|
||||
key: "job-added-to-production",
|
||||
table: "jobs",
|
||||
fields: ["inproduction"],
|
||||
onlyTruthyValues: ["inproduction"],
|
||||
builder: jobsAddedToProductionBuilder
|
||||
},
|
||||
{
|
||||
key: "alternate-transport-changed",
|
||||
table: "jobs",
|
||||
fields: ["alt_transport"],
|
||||
builder: alternateTransportChangedBuilder
|
||||
},
|
||||
{
|
||||
key: "new-time-ticket-posted",
|
||||
table: "timetickets",
|
||||
builder: newTimeTicketPostedBuilder
|
||||
},
|
||||
{
|
||||
key: "intake-delivery-checklist-completed",
|
||||
table: "jobs",
|
||||
fields: ["intakechecklist", "deliverchecklist"],
|
||||
builder: intakeDeliveryChecklistCompletedBuilder
|
||||
},
|
||||
{
|
||||
key: "payment-collected-completed",
|
||||
table: "payments",
|
||||
onNew: true,
|
||||
builder: paymentCollectedCompletedBuilder
|
||||
},
|
||||
{
|
||||
// MAKE SURE YOU ARE NOT ON A LMS ENVIRONMENT
|
||||
key: "new-media-added-reassigned",
|
||||
table: "documents",
|
||||
fields: ["jobid"],
|
||||
builder: newMediaAddedReassignedBuilder
|
||||
},
|
||||
{
|
||||
key: "critical-parts-status-changed",
|
||||
table: "joblines",
|
||||
fields: ["status"],
|
||||
onlyTruthyValues: ["status"],
|
||||
builder: criticalPartsStatusChangedBuilder,
|
||||
filterCallback: ({ eventData }) => !eventData?.data?.critical
|
||||
},
|
||||
{
|
||||
key: "part-marked-back-ordered",
|
||||
table: "joblines",
|
||||
fields: ["status"],
|
||||
builder: partMarkedBackOrderedBuilder,
|
||||
filterCallback: async ({ eventData, getBodyshopFromRedis }) => {
|
||||
try {
|
||||
const bodyshop = await getBodyshopFromRedis(eventData.bodyShopId);
|
||||
return eventData?.data?.status !== bodyshop?.md_order_statuses?.default_bo;
|
||||
} catch (err) {
|
||||
logger.log("notifications-error-parts-marked-back-ordered", "error", "notifications", "mapper", {
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
// -------------- Difficult ---------------
|
||||
// Holding off on this one for now
|
||||
{
|
||||
key: "supplement-imported",
|
||||
builder: supplementImportedBuilder
|
||||
// spans multiple tables,
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns an array of scenarios that match the given event data.
|
||||
* Supports asynchronous callbacks for additional filtering.
|
||||
*
|
||||
* @param {Object} eventData - The parsed event data.
|
||||
* Expected properties:
|
||||
* - table: an object with a `name` property (e.g. { name: "tasks", schema: "public" })
|
||||
* - changedFieldNames: an array of changed field names (e.g. [ "description", "updated_at" ])
|
||||
* - isNew: boolean indicating whether the record is new or updated
|
||||
* - data: the new data object (used to check field values)
|
||||
* - (other properties may be added such as jobWatchers, bodyShopId, etc.)
|
||||
* @param {Function} getBodyshopFromRedis - Function to retrieve bodyshop data from Redis.
|
||||
* @returns {Promise<Array<Object>>} A promise resolving to an array of matching scenario objects.
|
||||
*/
|
||||
const getMatchingScenarios = async (eventData, getBodyshopFromRedis) => {
|
||||
const matches = [];
|
||||
for (const scenario of notificationScenarios) {
|
||||
// If eventData has a table, then only scenarios with a table property that matches should be considered.
|
||||
if (eventData.table) {
|
||||
if (!scenario.table || eventData.table.name !== scenario.table) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check the onNew flag.
|
||||
// Allow onNew to be either a boolean or an array of booleans.
|
||||
if (Object.prototype.hasOwnProperty.call(scenario, "onNew")) {
|
||||
if (Array.isArray(scenario.onNew)) {
|
||||
if (!scenario.onNew.includes(eventData.isNew)) continue;
|
||||
} else {
|
||||
if (eventData.isNew !== scenario.onNew) continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If the scenario defines fields, ensure at least one of them is present in changedFieldNames.
|
||||
if (scenario.fields && scenario.fields.length > 0) {
|
||||
const hasMatchingField = scenario.fields.some((field) => eventData.changedFieldNames.includes(field));
|
||||
if (!hasMatchingField) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// OnlyTruthyValues logic:
|
||||
// If onlyTruthyValues is defined, check that the new values of specified fields (or all changed fields if true)
|
||||
// are truthy. If an array, only check the listed fields, which must be in scenario.fields.
|
||||
if (Object.prototype.hasOwnProperty.call(scenario, "onlyTruthyValues")) {
|
||||
let fieldsToCheck;
|
||||
|
||||
if (scenario.onlyTruthyValues === true) {
|
||||
// If true, check all fields in the scenario that changed
|
||||
fieldsToCheck = scenario.fields.filter((field) => eventData.changedFieldNames.includes(field));
|
||||
} else if (Array.isArray(scenario.onlyTruthyValues) && scenario.onlyTruthyValues.length > 0) {
|
||||
// If an array, check only the specified fields, ensuring they are in scenario.fields
|
||||
fieldsToCheck = scenario.onlyTruthyValues.filter(
|
||||
(field) => scenario.fields.includes(field) && eventData.changedFieldNames.includes(field)
|
||||
);
|
||||
// If no fields in onlyTruthyValues match the scenario’s fields or changed fields, skip this scenario
|
||||
if (fieldsToCheck.length === 0) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Invalid onlyTruthyValues (not true or a non-empty array), skip this scenario
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure all fields to check have truthy new values
|
||||
const allTruthy = fieldsToCheck.every((field) => Boolean(eventData.data[field]));
|
||||
if (!allTruthy) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the callback if defined, supporting both sync and async, and filter based on its return value
|
||||
if (isFunction(scenario?.filterCallback)) {
|
||||
const shouldFilter = await Promise.resolve(
|
||||
scenario.filterCallback({
|
||||
eventData,
|
||||
getBodyshopFromRedis
|
||||
})
|
||||
);
|
||||
if (shouldFilter) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
matches.push(scenario);
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
notificationScenarios,
|
||||
getMatchingScenarios
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user