Compare commits
81 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3877462eed | ||
|
|
01b18a4a02 | ||
|
|
17c4e2fd0e | ||
|
|
eb51085055 | ||
|
|
abd530b8b2 | ||
|
|
e4d437018d | ||
|
|
0767e290f4 | ||
|
|
b86309e74b | ||
|
|
7e2bd128e8 | ||
|
|
7f547c90c2 | ||
|
|
fa39e2b97e | ||
|
|
c5d00f7641 | ||
|
|
08b7f0e59c | ||
|
|
f0af12bc2c | ||
|
|
ace9ec792d | ||
|
|
015f4cc5bd | ||
|
|
4f1c0b9996 | ||
|
|
b395839b37 | ||
|
|
0f067fc503 | ||
|
|
a5cf81bd28 | ||
|
|
e892e4cab1 | ||
|
|
ef4bb75ce7 | ||
|
|
459af4f537 | ||
|
|
f860931eab | ||
|
|
0bf9f932b7 | ||
|
|
a077cf0820 | ||
|
|
c1abe98b89 | ||
|
|
0f32e6ffc7 | ||
|
|
eca7ff4a42 | ||
|
|
7d6b95d344 | ||
|
|
9e44ee2a26 | ||
|
|
5d0500582e | ||
|
|
f53fcc345e | ||
|
|
1b7cb7c852 | ||
|
|
c82cfb3ec2 | ||
|
|
cc5fea9410 | ||
|
|
29f7144e72 | ||
|
|
1384616d66 | ||
|
|
366f7b9c4a | ||
|
|
67e904e121 | ||
|
|
83ea51157d | ||
|
|
9f207f0946 | ||
|
|
2a81517104 | ||
|
|
00005c881e | ||
|
|
c1ea8e8a3d | ||
|
|
adb15a4748 | ||
|
|
c214ed1dfb | ||
|
|
c02c36c548 | ||
|
|
a15f86cc4e | ||
|
|
8a88a241d6 | ||
|
|
df13f257db | ||
|
|
5cfadf7929 | ||
|
|
4a46870327 | ||
|
|
4684bada1e | ||
|
|
163354f4b4 | ||
|
|
3d225c9f92 | ||
|
|
f3b2edea1c | ||
|
|
01e103fd0e | ||
|
|
1fc21e49a0 | ||
|
|
19d608e2b0 | ||
|
|
4b184d1d42 | ||
|
|
3f75041ad9 | ||
|
|
8c541dad05 | ||
|
|
921cca86c1 | ||
|
|
841312ebcd | ||
|
|
5ed00eaffe | ||
|
|
994ea8bb20 | ||
|
|
580641bae6 | ||
|
|
024b4fe21b | ||
|
|
72305f91d8 | ||
|
|
142617bc3d | ||
|
|
2ee582bfa2 | ||
|
|
54820fe3c8 | ||
|
|
b1ffbe0e12 | ||
|
|
ba2d03176f | ||
|
|
6d343e9b7f | ||
|
|
c27b1d802f | ||
|
|
f11d9dd804 | ||
|
|
996f5b3c71 | ||
|
|
9bb7f647a7 | ||
|
|
760f2ac7f9 |
620
client/package-lock.json
generated
620
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,26 +8,26 @@
|
||||
"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",
|
||||
"@sentry/cli": "^2.40.0",
|
||||
"@reduxjs/toolkit": "^2.6.0",
|
||||
"@sentry/cli": "^2.42.2",
|
||||
"@sentry/react": "^7.114.0",
|
||||
"@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": "^3.3.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",
|
||||
@@ -35,9 +35,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",
|
||||
@@ -47,7 +47,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",
|
||||
@@ -55,7 +55,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",
|
||||
@@ -63,9 +63,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",
|
||||
@@ -73,12 +73,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,13 +120,13 @@
|
||||
"@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",
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@sentry/webpack-plugin": "^2.22.4",
|
||||
"@testing-library/cypress": "^10.0.2",
|
||||
"browserslist": "^4.24.4",
|
||||
@@ -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
|
||||
@@ -46,6 +46,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
||||
const client = useSplitClient().client;
|
||||
const [listenersAdded, setListenersAdded] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!navigator.onLine) {
|
||||
@@ -200,7 +201,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
||||
path="/manage/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SocketProvider bodyshop={bodyshop}>
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
@@ -212,7 +213,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
||||
path="/tech/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SocketProvider bodyshop={bodyshop}>
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -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/socketContext";
|
||||
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/socketContext.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/socketContext.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/socketContext";
|
||||
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/socketContext.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/socketContext.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/socketContext.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/socketContext.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
|
||||
@@ -42,8 +43,7 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
||||
const { data: unreadData } = useQuery(UNREAD_CONVERSATION_COUNT, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
skip: chatVisible, // Skip when chat is visible
|
||||
...(pollInterval > 0 ? { pollInterval } : {})
|
||||
pollInterval: 60 * 1000 // TODO: This is a fix for now, should be coming from sockets
|
||||
});
|
||||
|
||||
// Socket connection status
|
||||
@@ -85,29 +85,25 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
||||
|
||||
// Get unread count from the cache
|
||||
const unreadCount = (() => {
|
||||
if (chatVisible) {
|
||||
try {
|
||||
const cachedData = client.readQuery({
|
||||
query: CONVERSATION_LIST_QUERY,
|
||||
variables: { offset: 0 }
|
||||
});
|
||||
try {
|
||||
const cachedData = client.readQuery({
|
||||
query: CONVERSATION_LIST_QUERY,
|
||||
variables: { offset: 0 }
|
||||
});
|
||||
|
||||
if (!cachedData?.conversations) return 0;
|
||||
|
||||
// Aggregate unread message count
|
||||
return cachedData.conversations.reduce((total, conversation) => {
|
||||
const unread = conversation.messages_aggregate?.aggregate?.count || 0;
|
||||
return total + unread;
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
console.warn("Unread count not found in cache:", error);
|
||||
return 0; // Fallback if not in cache
|
||||
if (!cachedData?.conversations) {
|
||||
return unreadData?.messages_aggregate?.aggregate?.count;
|
||||
}
|
||||
} else if (unreadData?.messages_aggregate?.aggregate?.count) {
|
||||
// Use the unread count from the query result
|
||||
return unreadData.messages_aggregate.aggregate.count;
|
||||
|
||||
// Aggregate unread message count
|
||||
return cachedData.conversations.reduce((total, conversation) => {
|
||||
const unread = conversation.messages_aggregate?.aggregate?.count || 0;
|
||||
return total + unread;
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
console.warn("Unread count not found in cache:", error);
|
||||
return 0; // Fallback if not in cache
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
|
||||
return (
|
||||
|
||||
@@ -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/socketContext.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);
|
||||
|
||||
|
||||
@@ -75,6 +75,22 @@ class ErrorBoundary extends React.Component {
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { error, info } = this.state;
|
||||
|
||||
const collapseItems = error
|
||||
? [
|
||||
{
|
||||
key: "errors",
|
||||
label: t("general.labels.errors"),
|
||||
children: (
|
||||
<div>
|
||||
<strong>{error.message || "Unknown error"}</strong>
|
||||
<div>{error.stack || "No stack trace available"}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
if (this.state.hasErrored === true) {
|
||||
logImEXEvent("error_boundary_rendered", { error, info });
|
||||
|
||||
@@ -122,14 +138,7 @@ class ErrorBoundary extends React.Component {
|
||||
/>
|
||||
<Row>
|
||||
<Col offset={6} span={12}>
|
||||
<Collapse bordered={false}>
|
||||
<Collapse.Panel header={t("general.labels.errors")}>
|
||||
<div>
|
||||
<strong>{this.state.error.message}</strong>
|
||||
</div>
|
||||
<div>{this.state.error.stack}</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<Collapse bordered={false} items={collapseItems} />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Icon, {
|
||||
BankFilled,
|
||||
BarChartOutlined,
|
||||
BellFilled,
|
||||
CarFilled,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleFilled,
|
||||
@@ -26,7 +27,7 @@ import Icon, {
|
||||
UserOutlined
|
||||
} from "@ant-design/icons";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { Layout, Menu, Space } from "antd";
|
||||
import { Badge, Layout, Menu, Space, Spin } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BsKanban } from "react-icons/bs";
|
||||
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
|
||||
@@ -44,6 +45,15 @@ import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selecto
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
|
||||
import { useState, useEffect } from "react";
|
||||
import { debounce } from "lodash";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
|
||||
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
|
||||
// Used to Determine if the Header is in Mobile Mode, and to toggle the multiple menus
|
||||
const HEADER_MOBILE_BREAKPOINT = 576;
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser,
|
||||
@@ -116,18 +126,64 @@ function Header({
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 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 { isConnected } = useSocket();
|
||||
const [notificationVisible, setNotificationVisible] = useState(false);
|
||||
|
||||
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||
|
||||
const {
|
||||
data: unreadData,
|
||||
refetch: refetchUnread,
|
||||
loading: unreadLoading
|
||||
} = useQuery(GET_UNREAD_COUNT, {
|
||||
variables: { associationid: userAssociationId },
|
||||
fetchPolicy: "network-only",
|
||||
pollInterval: isConnected ? 0 : 30000, // Poll only if socket is down
|
||||
skip: !userAssociationId // Skip query if no userAssociationId
|
||||
});
|
||||
|
||||
const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count ?? 0;
|
||||
|
||||
// Initial fetch and socket status handling
|
||||
useEffect(() => {
|
||||
if (userAssociationId) {
|
||||
refetchUnread().catch((e) => console.error(`Something went wrong fetching unread notifications: ${e?.message}`));
|
||||
}
|
||||
}, [refetchUnread, userAssociationId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected && !unreadLoading && userAssociationId) {
|
||||
refetchUnread().catch((e) => console.error(`Something went wrong fetching unread notifications: ${e?.message}`));
|
||||
}
|
||||
}, [isConnected, unreadLoading, refetchUnread, userAssociationId]);
|
||||
|
||||
const handleNotificationClick = (e) => {
|
||||
setNotificationVisible(!notificationVisible);
|
||||
if (handleMenuClick) handleMenuClick(e);
|
||||
};
|
||||
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
const effectiveWidth = window.innerWidth / (window.devicePixelRatio || 1);
|
||||
return effectiveWidth <= HEADER_MOBILE_BREAKPOINT;
|
||||
});
|
||||
|
||||
const handleResize = debounce(() => {
|
||||
const effectiveWidth = window.innerWidth / (window.devicePixelRatio || 1);
|
||||
setIsMobile(effectiveWidth <= HEADER_MOBILE_BREAKPOINT);
|
||||
}, 200);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("orientationchange", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("orientationchange", handleResize);
|
||||
handleResize.cancel(); // Cancel any pending debounced calls on cleanup
|
||||
};
|
||||
}, [handleResize]);
|
||||
|
||||
// Accounting children setup (unchanged)
|
||||
const accountingChildren = [];
|
||||
|
||||
accountingChildren.push(
|
||||
{
|
||||
key: "bills",
|
||||
@@ -350,6 +406,7 @@ function Header({
|
||||
children: accountingExportChildren
|
||||
});
|
||||
|
||||
// Define all menu items
|
||||
const menuItems = [
|
||||
{
|
||||
key: "home",
|
||||
@@ -419,7 +476,6 @@ function Header({
|
||||
icon: <ScheduleOutlined />,
|
||||
label: <Link to="/manage/production/list">{t("menus.header.productionlist")}</Link>
|
||||
},
|
||||
|
||||
{
|
||||
key: "productionboard",
|
||||
id: "header-production-board",
|
||||
@@ -432,7 +488,6 @@ function Header({
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
|
||||
{
|
||||
type: "divider",
|
||||
id: "header-jobs-divider3"
|
||||
@@ -519,7 +574,6 @@ function Header({
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
...(accountingChildren.length > 0
|
||||
? [
|
||||
{
|
||||
@@ -537,7 +591,6 @@ function Header({
|
||||
icon: <PhoneOutlined />,
|
||||
label: <Link to="/manage/phonebook">{t("menus.header.phonebook")}</Link>
|
||||
},
|
||||
|
||||
{
|
||||
key: "temporarydocs",
|
||||
id: "header-temporarydocs",
|
||||
@@ -550,7 +603,6 @@ function Header({
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
|
||||
{
|
||||
key: "tasks",
|
||||
id: "tasks",
|
||||
@@ -623,7 +675,6 @@ function Header({
|
||||
icon: <Icon component={IoBusinessOutline} />,
|
||||
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
|
||||
},
|
||||
|
||||
{
|
||||
key: "shop-csi",
|
||||
id: "header-shop-csi",
|
||||
@@ -638,9 +689,33 @@ function Header({
|
||||
}
|
||||
]
|
||||
},
|
||||
// Right-aligned items on desktop, merged on mobile
|
||||
{
|
||||
key: "notifications",
|
||||
icon: unreadLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<Badge count={unreadCount}>
|
||||
<BellFilled />
|
||||
</Badge>
|
||||
),
|
||||
id: "header-notifications",
|
||||
onClick: handleNotificationClick
|
||||
},
|
||||
{
|
||||
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>
|
||||
}))
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
label: currentUser.displayName || currentUser.email || t("general.labels.unknown"),
|
||||
icon: <UserOutlined />,
|
||||
// label: currentUser.displayName || currentUser.email || t("general.labels.unknown"),
|
||||
children: [
|
||||
{
|
||||
key: "signout",
|
||||
@@ -675,7 +750,6 @@ function Header({
|
||||
}
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
key: "shiftclock",
|
||||
id: "header-shiftclock",
|
||||
@@ -688,64 +762,68 @@ 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>
|
||||
}))
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout.Header>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme={"dark"}
|
||||
selectedKeys={[selectedHeader]}
|
||||
onClick={handleMenuClick}
|
||||
subMenuCloseDelay={0.3}
|
||||
items={menuItems}
|
||||
/>
|
||||
<Layout.Header style={{ padding: 0 }}>
|
||||
{isMobile ? (
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
selectedKeys={[selectedHeader]}
|
||||
onClick={handleMenuClick}
|
||||
subMenuCloseDelay={0.3}
|
||||
items={menuItems}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
width: "100%"
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
selectedKeys={[selectedHeader]}
|
||||
onClick={handleMenuClick}
|
||||
subMenuCloseDelay={0.3}
|
||||
items={menuItems.slice(0, -3)}
|
||||
style={{
|
||||
flex: "0 1 auto",
|
||||
justifyContent: "flex-start",
|
||||
minWidth: 0,
|
||||
overflow: "visible"
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: "1 0 0" }} />
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
selectedKeys={[selectedHeader]}
|
||||
onClick={handleMenuClick}
|
||||
subMenuCloseDelay={0.3}
|
||||
items={menuItems.slice(-3)}
|
||||
style={{
|
||||
flex: "0 0 auto",
|
||||
justifyContent: "flex-end",
|
||||
overflow: "visible"
|
||||
}}
|
||||
/>
|
||||
<NotificationCenterContainer visible={notificationVisible} onClose={() => setNotificationVisible(false)} />
|
||||
</div>
|
||||
)}
|
||||
</Layout.Header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,8 @@
|
||||
import i18next from "i18next";
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { setUserLanguage } from "../../redux/user/user.actions";
|
||||
import HeaderComponent from "./header.component";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setUserLanguage: (language) => dispatch(setUserLanguage(language))
|
||||
});
|
||||
|
||||
export function HeaderContainer({ setUserLanguage }) {
|
||||
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} />;
|
||||
export function HeaderContainer() {
|
||||
return <HeaderComponent />;
|
||||
}
|
||||
|
||||
export default connect(null, mapDispatchToProps)(HeaderContainer);
|
||||
export default connect(null, null)(HeaderContainer);
|
||||
|
||||
@@ -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/socketContext.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 = (
|
||||
|
||||
@@ -16,7 +16,6 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import JobCloseRoGuardTtLifecycle from "./job-close-ro-guard.tt-lifecycle";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
bodyshop: selectBodyshop,
|
||||
jobRO: selectJobReadOnly
|
||||
});
|
||||
@@ -40,34 +39,13 @@ export function JobCloseRoGuardContainer({ job, jobRO, bodyshop, form }) {
|
||||
|
||||
if (!bodyshop?.md_ro_guard?.enabled) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{warnings.length > 0 && (
|
||||
<Card
|
||||
title={
|
||||
<Space size="small">
|
||||
<Badge count={warnings.length} />
|
||||
{t("jobs.labels.roguardwarnings")}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<ul>
|
||||
{warnings.map((w, index) => (
|
||||
<li key={index}>
|
||||
{bodyshop.md_ro_guard[`enforce_${w.key}`] && (
|
||||
<Tooltip title={t("jobs.labels.ro_guard.enforced")}>
|
||||
<LockOutlined style={{ color: "tomato", marginRight: "8px" }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{w.warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Collapse>
|
||||
<Collapse.Panel forceRender key="roguard" header={t("jobs.labels.roguard")}>
|
||||
// Define collapse items for both panels
|
||||
const collapseItems = [
|
||||
{
|
||||
key: "roguard",
|
||||
label: t("jobs.labels.roguard"),
|
||||
children: (
|
||||
<>
|
||||
<Row gutter={[32, 32]}>
|
||||
<Col span={24}>
|
||||
<JobCloseRoGuardBills job={job} form={form} warningCallback={warningCallback} />
|
||||
@@ -85,7 +63,6 @@ export function JobCloseRoGuardContainer({ job, jobRO, bodyshop, form }) {
|
||||
{InstanceRenderManager({
|
||||
rome: (
|
||||
<Col md={24} lg={8}>
|
||||
{/* <JobCloseRoGuardSublet job={job} warningCallback={warningCallback} /> */}
|
||||
<JobCloseRoGuardPpd job={job} warningCallback={warningCallback} />
|
||||
</Col>
|
||||
)
|
||||
@@ -214,16 +191,50 @@ export function JobCloseRoGuardContainer({ job, jobRO, bodyshop, form }) {
|
||||
>
|
||||
<Input prefix={<LockOutlined />} type="password" placeholder="Password" disabled={jobRO} />
|
||||
</Form.Item>
|
||||
</Collapse.Panel>
|
||||
</>
|
||||
),
|
||||
forceRender: true // Preserve the forceRender prop from the original
|
||||
},
|
||||
{
|
||||
key: "performance",
|
||||
label: t("jobs.labels.performance"),
|
||||
children: (
|
||||
<Row gutter={[32, 32]}>
|
||||
<Col className="ro-guard-col" span={24}>
|
||||
<JobCloseRoGuardTtLifecycle job={job} />
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
<Collapse.Panel header={t("jobs.labels.performance")}>
|
||||
<Row gutter={[32, 32]}>
|
||||
<Col className="ro-guard-col" span={24}>
|
||||
<JobCloseRoGuardTtLifecycle job={job} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
return (
|
||||
<>
|
||||
{warnings.length > 0 && (
|
||||
<Card
|
||||
title={
|
||||
<Space size="small">
|
||||
<Badge count={warnings.length} />
|
||||
{t("jobs.labels.roguardwarnings")}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<ul>
|
||||
{warnings.map((w, index) => (
|
||||
<li key={index}>
|
||||
{bodyshop.md_ro_guard[`enforce_${w.key}`] && (
|
||||
<Tooltip title={t("jobs.labels.ro_guard.enforced")}>
|
||||
<LockOutlined style={{ color: "tomato", marginRight: "8px" }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{w.warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Collapse items={collapseItems} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const colSpan = {
|
||||
export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!!!job.job_totals) {
|
||||
if (!job.job_totals) {
|
||||
return (
|
||||
<Card>
|
||||
<Result title={t("jobs.errors.nofinancial")} extra={<JobCalculateTotals job={job} disabled={jobRO} />} />
|
||||
@@ -35,6 +35,29 @@ export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Define collapse items
|
||||
const collapseItems = [
|
||||
{
|
||||
key: "json-tree-totals",
|
||||
label: "JSON Tree Totals",
|
||||
children: (
|
||||
<div>
|
||||
<pre>
|
||||
{JSON.stringify(
|
||||
{
|
||||
CIECA: job.cieca_ttl && job.cieca_ttl.data,
|
||||
CIECASTL: job.cieca_stl && job.cieca_stl.data,
|
||||
ImEXCalc: job.job_totals
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
@@ -68,23 +91,7 @@ export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
|
||||
<Col span={24}>
|
||||
<Card title="DEVELOPMENT USE ONLY">
|
||||
<JobCalculateTotals job={job} disabled={jobRO} />
|
||||
<Collapse>
|
||||
<Collapse.Panel header="JSON Tree Totals">
|
||||
<div>
|
||||
<pre>
|
||||
{JSON.stringify(
|
||||
{
|
||||
CIECA: job.cieca_ttl && job.cieca_ttl.data,
|
||||
CIECASTL: job.cieca_stl && job.cieca_stl.data,
|
||||
ImEXCalc: job.job_totals
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<Collapse items={collapseItems} />
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
@@ -20,7 +20,6 @@ import JobsMarkPstExempt from "../jobs-mark-pst-exempt/jobs-mark-pst-exempt.comp
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
@@ -39,162 +38,175 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Collapse defaultActiveKey="insurance">
|
||||
<Collapse.Panel key="insurance" header={t("menus.jobsdetail.insurance")} forceRender>
|
||||
<LayoutFormRow>
|
||||
<Form.Item label={t("jobs.fields.clm_no")} name="clm_no">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.regie_number")} name="regie_number">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
|
||||
<Select onChange={handleInsCoChange}>
|
||||
{bodyshop.md_ins_cos.map((s) => (
|
||||
<Select.Option key={s.name} value={s.name}>
|
||||
{s.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_city")} name="ins_city">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<Space>
|
||||
{t("jobs.fields.ins_ct_ln")}
|
||||
<JobsDetailChangeFilehandler form={form} />
|
||||
</Space>
|
||||
// Define collapse items for all three panels
|
||||
const collapseItems = [
|
||||
{
|
||||
key: "insurance",
|
||||
label: t("menus.jobsdetail.insurance"),
|
||||
children: (
|
||||
<LayoutFormRow>
|
||||
<Form.Item label={t("jobs.fields.clm_no")} name="clm_no">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.regie_number")} name="regie_number">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
|
||||
<Select onChange={handleInsCoChange}>
|
||||
{bodyshop.md_ins_cos.map((s) => (
|
||||
<Select.Option key={s.name} value={s.name}>
|
||||
{s.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_city")} name="ins_city">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<Space>
|
||||
{t("jobs.fields.ins_ct_ln")}
|
||||
<JobsDetailChangeFilehandler form={form} />
|
||||
</Space>
|
||||
}
|
||||
name="ins_ct_ln"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_ct_fn")} name="ins_ct_fn">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.ins_ph1")}
|
||||
name="ins_ph1"
|
||||
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ins_ph1")]}
|
||||
>
|
||||
<FormItemPhone />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.ins_ea")}
|
||||
name="ins_ea"
|
||||
rules={[
|
||||
{
|
||||
type: "email",
|
||||
message: "This is not a valid email address."
|
||||
}
|
||||
name="ins_ct_ln"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_ct_fn")} name="ins_ct_fn">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.ins_ph1")}
|
||||
name="ins_ph1"
|
||||
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ins_ph1")]}
|
||||
>
|
||||
<FormItemPhone />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.ins_ea")}
|
||||
name="ins_ea"
|
||||
rules={[
|
||||
{
|
||||
type: "email",
|
||||
message: "This is not a valid email address."
|
||||
}
|
||||
]}
|
||||
>
|
||||
<FormItemEmail email={getFieldValue("ins_ea")} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.loss_date")} name="loss_date">
|
||||
<DateTimePicker isDateOnly />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.kmin")} name="kmin">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.est_co_nm")} name="est_co_nm">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<Space>
|
||||
{t("jobs.fields.est_ct_fn")}
|
||||
<JobsDetailChangeEstimator form={form} />
|
||||
</Space>
|
||||
]}
|
||||
>
|
||||
<FormItemEmail email={getFieldValue("ins_ea")} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.loss_date")} name="loss_date">
|
||||
<DateTimePicker isDateOnly />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.kmin")} name="kmin">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.est_co_nm")} name="est_co_nm">
|
||||
sausage <Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<Space>
|
||||
{t("jobs.fields.est_ct_fn")}
|
||||
<JobsDetailChangeEstimator form={form} />
|
||||
</Space>
|
||||
}
|
||||
name="est_ct_fn"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.est_ct_ln")} name="est_ct_ln">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.est_ph1")} name="est_ph1">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.est_ea")}
|
||||
name="est_ea"
|
||||
rules={[
|
||||
{
|
||||
type: "email",
|
||||
message: "This is not a valid email address."
|
||||
}
|
||||
name="est_ct_fn"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.est_ct_ln")} name="est_ct_ln">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.est_ph1")} name="est_ph1">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.est_ea")}
|
||||
name="est_ea"
|
||||
rules={[
|
||||
{
|
||||
type: "email",
|
||||
message: "This is not a valid email address."
|
||||
}
|
||||
]}
|
||||
>
|
||||
<FormItemEmail email={getFieldValue("est_ea")} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.servicing_dealer")} name="servicing_dealer">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.selling_dealer_contact")} name="selling_dealer_contact">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.servicing_dealer_contact")} name="servicing_dealer_contact">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</Collapse.Panel>
|
||||
<Collapse.Panel forceRender key="claim" header={t("menus.jobsdetail.claimdetail")}>
|
||||
<LayoutFormRow>
|
||||
<Form.Item label={t("jobs.fields.loss_desc")} name="loss_desc">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.loss_of_use")} name="loss_of_use">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ponumber")} name="po_number">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.unitnumber")} name="unit_number">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.specialcoveragepolicy")}
|
||||
valuePropName="checked"
|
||||
name="special_coverage_policy"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.kmin")} name="kmin">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.kmout")} name="kmout">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
|
||||
<Select>
|
||||
{bodyshop.md_referral_sources.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</Collapse.Panel>
|
||||
<Collapse.Panel forceRender key="financial" header={t("menus.jobsdetail.financials")}>
|
||||
]}
|
||||
>
|
||||
<FormItemEmail email={getFieldValue("est_ea")} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.servicing_dealer")} name="servicing_dealer">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.selling_dealer_contact")} name="selling_dealer_contact">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.servicing_dealer_contact")} name="servicing_dealer_contact">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
),
|
||||
forceRender: true
|
||||
},
|
||||
{
|
||||
key: "claim",
|
||||
label: t("menus.jobsdetail.claimdetail"),
|
||||
children: (
|
||||
<LayoutFormRow>
|
||||
<Form.Item label={t("jobs.fields.loss_desc")} name="loss_desc">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.loss_of_use")} name="loss_of_use">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ponumber")} name="po_number">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.unitnumber")} name="unit_number">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.specialcoveragepolicy")}
|
||||
valuePropName="checked"
|
||||
name="special_coverage_policy"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.kmin")} name="kmin">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.kmout")} name="kmout">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
|
||||
<Select>
|
||||
{bodyshop.md_referral_sources.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
),
|
||||
forceRender: true
|
||||
},
|
||||
{
|
||||
key: "financial",
|
||||
label: t("menus.jobsdetail.financials"),
|
||||
children: (
|
||||
<>
|
||||
<JobsDetailRatesChangeButton form={form} />
|
||||
{InstanceRenderManager({
|
||||
imex: <JobsMarkPstExempt form={form} />
|
||||
@@ -315,8 +327,15 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
|
||||
<CurrencyInput />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</>
|
||||
),
|
||||
forceRender: true
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Collapse defaultActiveKey="insurance" items={collapseItems} />
|
||||
<JobsDetailRatesParts jobRO={false} expanded required={selected && true} form={form} />
|
||||
{InstanceRenderManager({
|
||||
rome: (
|
||||
|
||||
@@ -4,12 +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 SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.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 +130,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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,154 +13,162 @@ const mapStateToProps = createStructuredSelector({
|
||||
export function JobsDetailRatesMaterials({ jobRO, expanded, required = true, form }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Collapse defaultActiveKey={expanded && "rates"}>
|
||||
<Collapse.Panel forceRender header={t("jobs.fields.materials.materials")} key="materials">
|
||||
<LayoutFormRow header={t("jobs.fields.materials.MAPA")}>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_maxdlr")} name={["materials", "MAPA", "cal_maxdlr"]}>
|
||||
<InputNumber min={0} precision={2} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_opcode")} name={["materials", "MAPA", "cal_opcode"]}>
|
||||
<Input disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.mat_adjp")} name={["materials", "MAPA", "mat_adjp"]}>
|
||||
<InputNumber min={-100} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.tax_ind")}
|
||||
name={["materials", "MAPA", "tax_ind"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate>
|
||||
{() => {
|
||||
return (
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_taxp")}
|
||||
name={["materials", "MAPA", "mat_taxp"]}
|
||||
rules={[
|
||||
{
|
||||
required: form.getFieldValue(["materials", "MAPA", "tax_ind"])
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in1")}
|
||||
name={["materials", "MAPA", "mat_tx_in1"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in2")}
|
||||
name={["materials", "MAPA", "mat_tx_in2"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in3")}
|
||||
name={["materials", "MAPA", "mat_tx_in3"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in4")}
|
||||
name={["materials", "MAPA", "mat_tx_in4"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in5")}
|
||||
name={["materials", "MAPA", "mat_tx_in5"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<LayoutFormRow header={t("jobs.fields.materials.MASH")}>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_maxdlr")} name={["materials", "MASH", "cal_maxdlr"]}>
|
||||
<InputNumber min={0} precision={2} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_opcode")} name={["materials", "MASH", "cal_opcode"]}>
|
||||
<Input disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.mat_adjp")} name={["materials", "MAPA", "mat_adjp"]}>
|
||||
<InputNumber min={-100} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.tax_ind")}
|
||||
name={["materials", "MASH", "tax_ind"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate>
|
||||
{() => {
|
||||
return (
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_taxp")}
|
||||
name={["materials", "MASH", "mat_taxp"]}
|
||||
rules={[
|
||||
{
|
||||
required: form.getFieldValue(["materials", "MASH", "tax_ind"])
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in1")}
|
||||
name={["materials", "MASH", "mat_tx_in1"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in2")}
|
||||
name={["materials", "MASH", "mat_tx_in2"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in3")}
|
||||
name={["materials", "MASH", "mat_tx_in3"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in4")}
|
||||
name={["materials", "MASH", "mat_tx_in4"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in5")}
|
||||
name={["materials", "MASH", "mat_tx_in5"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
);
|
||||
// Define collapse items
|
||||
const collapseItems = [
|
||||
{
|
||||
key: "materials",
|
||||
label: t("jobs.fields.materials.materials"),
|
||||
children: (
|
||||
<>
|
||||
<LayoutFormRow header={t("jobs.fields.materials.MAPA")}>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_maxdlr")} name={["materials", "MAPA", "cal_maxdlr"]}>
|
||||
<InputNumber min={0} precision={2} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_opcode")} name={["materials", "MAPA", "cal_opcode"]}>
|
||||
<Input disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.mat_adjp")} name={["materials", "MAPA", "mat_adjp"]}>
|
||||
<InputNumber min={-100} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.tax_ind")}
|
||||
name={["materials", "MAPA", "tax_ind"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate>
|
||||
{() => {
|
||||
return (
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_taxp")}
|
||||
name={["materials", "MAPA", "mat_taxp"]}
|
||||
rules={[
|
||||
{
|
||||
required: form.getFieldValue(["materials", "MAPA", "tax_ind"])
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in1")}
|
||||
name={["materials", "MAPA", "mat_tx_in1"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in2")}
|
||||
name={["materials", "MAPA", "mat_tx_in2"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in3")}
|
||||
name={["materials", "MAPA", "mat_tx_in3"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in4")}
|
||||
name={["materials", "MAPA", "mat_tx_in4"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in5")}
|
||||
name={["materials", "MAPA", "mat_tx_in5"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<LayoutFormRow header={t("jobs.fields.materials.MASH")}>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_maxdlr")} name={["materials", "MASH", "cal_maxdlr"]}>
|
||||
<InputNumber min={0} precision={2} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_opcode")} name={["materials", "MASH", "cal_opcode"]}>
|
||||
<Input disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.mat_adjp")} name={["materials", "MAPA", "mat_adjp"]}>
|
||||
<InputNumber min={-100} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.tax_ind")}
|
||||
name={["materials", "MASH", "tax_ind"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate>
|
||||
{() => {
|
||||
return (
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_taxp")}
|
||||
name={["materials", "MASH", "mat_taxp"]}
|
||||
rules={[
|
||||
{
|
||||
required: form.getFieldValue(["materials", "MASH", "tax_ind"])
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in1")}
|
||||
name={["materials", "MASH", "mat_tx_in1"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in2")}
|
||||
name={["materials", "MASH", "mat_tx_in2"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in3")}
|
||||
name={["materials", "MASH", "mat_tx_in3"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in4")}
|
||||
name={["materials", "MASH", "mat_tx_in4"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in5")}
|
||||
name={["materials", "MASH", "mat_tx_in5"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</>
|
||||
),
|
||||
forceRender: true
|
||||
}
|
||||
];
|
||||
|
||||
return <Collapse defaultActiveKey={expanded && "rates"} items={collapseItems} />;
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, null)(JobsDetailRatesMaterials);
|
||||
|
||||
@@ -13,9 +13,12 @@ const mapStateToProps = createStructuredSelector({
|
||||
export function JobsDetailRatesOther({ jobRO, expanded, required = true, form }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Collapse defaultActiveKey={expanded && "rates"}>
|
||||
<Collapse.Panel forceRender header={t("jobs.labels.cieca_pfo")} key="cieca_pfo">
|
||||
// Define collapse items
|
||||
const collapseItems = [
|
||||
{
|
||||
key: "cieca_pfo",
|
||||
label: t("jobs.labels.cieca_pfo"),
|
||||
children: (
|
||||
<LayoutFormRow noDivider>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.cieca_pfo.tow_t_in1")}
|
||||
@@ -52,7 +55,6 @@ export function JobsDetailRatesOther({ jobRO, expanded, required = true, form })
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("jobs.fields.cieca_pfo.stor_t_in1")}
|
||||
name={["cieca_pfo", "stor_t_in1"]}
|
||||
@@ -89,9 +91,12 @@ export function JobsDetailRatesOther({ jobRO, expanded, required = true, form })
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
);
|
||||
),
|
||||
forceRender: true
|
||||
}
|
||||
];
|
||||
|
||||
return <Collapse defaultActiveKey={expanded && "rates"} items={collapseItems} />;
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, null)(JobsDetailRatesOther);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,9 @@ const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
//If the panel doesn’t expand correctly due to the key mismatch, update the Collapse line to:
|
||||
// <Collapse defaultActiveKey={expanded && "cieca_pft"} items={collapseItems} />
|
||||
|
||||
export function JobsDetailRatesTaxes({ jobRO, expanded, bodyshop, required = true, form }) {
|
||||
const { t } = useTranslation();
|
||||
const formItems = [];
|
||||
@@ -47,13 +50,18 @@ export function JobsDetailRatesTaxes({ jobRO, expanded, bodyshop, required = tru
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Collapse defaultActiveKey={expanded && "rates"}>
|
||||
<Collapse.Panel forceRender header={t("jobs.labels.cieca_pft")} key="cieca_pft">
|
||||
{formItems}
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
);
|
||||
|
||||
// Define collapse items
|
||||
const collapseItems = [
|
||||
{
|
||||
key: "cieca_pft",
|
||||
label: t("jobs.labels.cieca_pft"),
|
||||
children: <>{formItems}</>,
|
||||
forceRender: true
|
||||
}
|
||||
];
|
||||
|
||||
return <Collapse defaultActiveKey={expanded && "rates"} items={collapseItems} />;
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, null)(JobsDetailRatesTaxes);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// notification-center.component.jsx
|
||||
import React from "react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { Button, Checkbox, List, Badge, Typography, Alert } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./notification-center.styles.scss";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const NotificationCenterComponent = ({
|
||||
visible,
|
||||
onClose,
|
||||
notifications,
|
||||
loading,
|
||||
error,
|
||||
showUnreadOnly,
|
||||
toggleUnreadOnly,
|
||||
markAllRead,
|
||||
loadMore,
|
||||
onNotificationClick
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderNotification = (index, notification) => {
|
||||
return (
|
||||
<List.Item
|
||||
key={`${notification.id}-${index}`}
|
||||
className={notification.read ? "notification-read" : "notification-unread"}
|
||||
onClick={() => !notification.read && onNotificationClick(notification.id)}
|
||||
>
|
||||
<Badge dot={!notification.read}>
|
||||
<div>
|
||||
<Title
|
||||
level={5}
|
||||
style={{
|
||||
margin: "0 0 8px 0",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
to={`/manage/jobs/${notification.jobid}`}
|
||||
target="_blank"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent List.Item click handler from firing
|
||||
!notification.read && onNotificationClick(notification.id); // Mark as read when link clicked
|
||||
}}
|
||||
>
|
||||
RO #{notification.roNumber}
|
||||
</Link>
|
||||
<Text type="secondary">{new Date(notification.created_at).toLocaleString()}</Text>
|
||||
</Title>
|
||||
<Text strong={!notification.read}>
|
||||
<ul>
|
||||
{notification.scenarioText.map((text, idx) => (
|
||||
<li key={`${notification.id}-${idx}`}>{text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Text>
|
||||
</div>
|
||||
</Badge>
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`notification-center ${visible ? "visible" : ""}`}>
|
||||
<div className="notification-header">
|
||||
<h3>{t("notifications.labels.notification-center")}</h3>
|
||||
<div className="notification-controls">
|
||||
<Checkbox checked={showUnreadOnly} onChange={(e) => toggleUnreadOnly(e.target.checked)}>
|
||||
{t("notifications.labels.show-unread-only")}
|
||||
</Checkbox>
|
||||
<Button type="link" onClick={markAllRead} disabled={!notifications.some((n) => !n.read)}>
|
||||
{t("notifications.labels.mark-all-read")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <Alert message="Error" description={error} type="error" closable onClose={() => onClose()} />}
|
||||
<Virtuoso
|
||||
style={{ height: "400px", width: "100%" }}
|
||||
data={notifications}
|
||||
totalCount={notifications.length}
|
||||
endReached={loadMore}
|
||||
itemContent={renderNotification}
|
||||
/>
|
||||
{loading && !error && <div>{t("notifications.labels.loading")}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenterComponent;
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useCallback, useEffect, useMemo, 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/socketContext.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
|
||||
export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
|
||||
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket();
|
||||
|
||||
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,
|
||||
error: queryError,
|
||||
refetch
|
||||
} = useQuery(GET_NOTIFICATIONS, {
|
||||
variables: {
|
||||
limit: INITIAL_NOTIFICATIONS,
|
||||
offset: 0,
|
||||
where: whereClause
|
||||
},
|
||||
fetchPolicy: "cache-and-network",
|
||||
notifyOnNetworkStatusChange: true,
|
||||
pollInterval: isConnected ? 0 : 30000,
|
||||
skip: !userAssociationId,
|
||||
onError: (err) => {
|
||||
setError(err.message);
|
||||
console.error("GET_NOTIFICATIONS error:", err);
|
||||
setTimeout(() => refetch(), 2000);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
setError(null);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryError) {
|
||||
setError(queryError.message);
|
||||
}
|
||||
}, [queryError]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!loading && data?.notifications.length) {
|
||||
fetchMore({
|
||||
variables: { offset: data.notifications.length, where: whereClause },
|
||||
updateQuery: (prev, { fetchMoreResult }) => {
|
||||
if (!fetchMoreResult) return prev;
|
||||
return {
|
||||
notifications: [...prev.notifications, ...fetchMoreResult.notifications]
|
||||
};
|
||||
}
|
||||
}).catch((err) => {
|
||||
setError(err.message);
|
||||
console.error("Fetch more error:", err);
|
||||
});
|
||||
}
|
||||
}, [data?.notifications?.length, fetchMore, loading, whereClause]);
|
||||
|
||||
const handleToggleUnreadOnly = (value) => {
|
||||
setShowUnreadOnly(value);
|
||||
};
|
||||
|
||||
const handleMarkAllRead = useCallback(() => {
|
||||
markAllNotificationsRead()
|
||||
.then(() => {
|
||||
const timestamp = new Date().toISOString();
|
||||
setNotifications((prev) => {
|
||||
const updatedNotifications = prev.map((notif) =>
|
||||
notif.read === null && notif.associationid === userAssociationId
|
||||
? {
|
||||
...notif,
|
||||
read: timestamp
|
||||
}
|
||||
: notif
|
||||
);
|
||||
return [...updatedNotifications];
|
||||
});
|
||||
})
|
||||
.catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`));
|
||||
}, [markAllNotificationsRead, userAssociationId]);
|
||||
|
||||
const handleNotificationClick = useCallback(
|
||||
(notificationId) => {
|
||||
markNotificationRead({
|
||||
variables: { id: notificationId }
|
||||
})
|
||||
.then(() => {
|
||||
const timestamp = new Date().toISOString();
|
||||
setNotifications((prev) => {
|
||||
return prev.map((notif) =>
|
||||
notif.id === notificationId && !notif.read ? { ...notif, read: timestamp } : notif
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`));
|
||||
},
|
||||
[markNotificationRead]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && !isConnected) {
|
||||
refetch().catch(
|
||||
(err) => `Something went wrong re-fetching notifications in the notification-center: ${err?.message || ""}`
|
||||
);
|
||||
}
|
||||
}, [visible, isConnected, refetch]);
|
||||
|
||||
return (
|
||||
<NotificationCenterComponent
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
notifications={notifications}
|
||||
loading={loading}
|
||||
error={error}
|
||||
showUnreadOnly={showUnreadOnly}
|
||||
toggleUnreadOnly={handleToggleUnreadOnly}
|
||||
markAllRead={handleMarkAllRead}
|
||||
loadMore={loadMore}
|
||||
onNotificationClick={handleNotificationClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(NotificationCenterContainer);
|
||||
@@ -0,0 +1,113 @@
|
||||
.notification-center {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
right: 0;
|
||||
//width: 600px;
|
||||
background: #fff; /* White background, Ant’s default */
|
||||
color: rgba(0, 0, 0, 0.85); /* Primary text color in Ant 5 */
|
||||
border: 1px solid #d9d9d9; /* Neutral gray border */
|
||||
border-radius: 6px; /* Slightly larger radius per Ant 5 */
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06); /* Subtle Ant 5 shadow */
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
|
||||
&.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0; /* Light gray border from Ant 5 */
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #fafafa; /* Light gray background for header */
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, 0.85); /* Primary text color */
|
||||
}
|
||||
|
||||
.notification-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.ant-checkbox-wrapper {
|
||||
color: rgba(0, 0, 0, 0.85); /* Match Ant’s text color */
|
||||
}
|
||||
|
||||
.ant-btn-link {
|
||||
color: #1677ff; /* Ant 5 primary blue */
|
||||
&:hover {
|
||||
color: #69b1ff; /* Lighter blue on hover */
|
||||
}
|
||||
&:disabled {
|
||||
color: rgba(0, 0, 0, 0.25); /* Disabled text color from Ant 5 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notification-read {
|
||||
background: #fff; /* White background for read items */
|
||||
color: rgba(0, 0, 0, 0.65); /* Secondary text color */
|
||||
}
|
||||
|
||||
.notification-unread {
|
||||
background: #f5f5f5; /* Very light gray for unread items */
|
||||
color: rgba(0, 0, 0, 0.85); /* Primary text color */
|
||||
}
|
||||
|
||||
.ant-list {
|
||||
overflow: auto; /* Match Virtuoso’s default scrolling behavior */
|
||||
max-height: 100%; /* Allow full height, let Virtuoso handle virtualization */
|
||||
}
|
||||
|
||||
.ant-list-item {
|
||||
padding: 2px 16px;
|
||||
border-bottom: 1px solid #f0f0f0; /* Light gray border */
|
||||
display: block; /* Ensure visibility */
|
||||
overflow: visible; /* Prevent clipping within items */
|
||||
min-height: 80px; /* Minimum height for multi-line content */
|
||||
|
||||
.ant-typography {
|
||||
color: inherit; /* Inherit from parent (read/unread) */
|
||||
}
|
||||
|
||||
.ant-typography-secondary {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45); /* Ant 5 secondary text color */
|
||||
}
|
||||
|
||||
.ant-badge-dot {
|
||||
background: #ff4d4f; /* Keep red dot for unread, consistent with Ant */
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px; /* Standard list padding */
|
||||
list-style-type: disc; /* Ensure bullet points */
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 4px; /* Space between list items */
|
||||
}
|
||||
}
|
||||
|
||||
.ant-alert {
|
||||
margin: 8px;
|
||||
background: #fff1f0; /* Light red background for error per Ant 5 */
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
border: 1px solid #ffa39e; /* Light red border */
|
||||
|
||||
.ant-alert-message {
|
||||
color: #ff4d4f; /* Red text for message */
|
||||
}
|
||||
|
||||
.ant-alert-description {
|
||||
color: rgba(0, 0, 0, 0.65); /* Slightly muted description */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/socketContext.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/socketContext.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
|
||||
|
||||
@@ -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/socketContext.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
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Card, Checkbox, Form, 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";
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
function NotificationSettingsForm({ currentUser }) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const [initialValues, setInitialValues] = useState({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
// 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.
|
||||
await updateNotificationSettings({ variables: { id: userId, ns: values } });
|
||||
setInitialValues(values);
|
||||
setIsDirty(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 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={
|
||||
<>
|
||||
<Button type="default" onClick={handleReset} disabled={!isDirty} style={{ marginRight: 8 }}>
|
||||
{t("general.actions.clear")}
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={saving}>
|
||||
{t("notifications.labels.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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);
|
||||
@@ -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,7 @@ 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 NotificationSettingsForm from "./notification-settings.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser
|
||||
@@ -117,6 +117,9 @@ export default connect(
|
||||
</Card>
|
||||
</Form>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<NotificationSettingsForm />
|
||||
</Col>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ export function ScheduleCalendarWrapperComponent({
|
||||
const search = queryString.parse(useLocation().search);
|
||||
const history = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleEventPropStyles = (event, start, end, isSelected) => {
|
||||
return {
|
||||
...(event.color && !((search.view || defaultView) === "agenda")
|
||||
@@ -51,37 +52,41 @@ export function ScheduleCalendarWrapperComponent({
|
||||
|
||||
const selectedDate = new Date(date || dayjs(search.date) || Date.now());
|
||||
|
||||
// Convert Collapse to use items prop
|
||||
const collapseItems = [
|
||||
{
|
||||
key: "1",
|
||||
label: <span style={{ color: "tomato" }}>{t("appointments.labels.severalerrorsfound")}</span>,
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{problemJobs.map((problem) => (
|
||||
<Alert
|
||||
key={problem.id}
|
||||
type="error"
|
||||
message={
|
||||
<Trans
|
||||
i18nKey="appointments.labels.dataconsistency"
|
||||
components={[<Link to={`/manage/jobs/${problem.id}`} target="_blank" />]}
|
||||
values={{
|
||||
ro_number: problem.ro_number,
|
||||
code: problem.code
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<JobDetailCards />
|
||||
{HasFeatureAccess({ featureName: "smartscheduling", bodyshop }) &&
|
||||
problemJobs &&
|
||||
(problemJobs.length > 2 ? (
|
||||
<Collapse style={{ marginBottom: "5px" }}>
|
||||
<Collapse.Panel
|
||||
key="1"
|
||||
header={<span style={{ color: "tomato" }}>{t("appointments.labels.severalerrorsfound")}</span>}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{problemJobs.map((problem) => (
|
||||
<Alert
|
||||
key={problem.id}
|
||||
type="error"
|
||||
message={
|
||||
<Trans
|
||||
i18nKey="appointments.labels.dataconsistency"
|
||||
components={[<Link to={`/manage/jobs/${problem.id}`} target="_blank" />]}
|
||||
values={{
|
||||
ro_number: problem.ro_number,
|
||||
code: problem.code
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<Collapse items={collapseItems} style={{ marginBottom: "5px" }} />
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: "100%", marginBottom: "5px" }}>
|
||||
{problemJobs.map((problem) => (
|
||||
@@ -119,7 +124,6 @@ export function ScheduleCalendarWrapperComponent({
|
||||
history({ search: queryString.stringify(search) });
|
||||
}}
|
||||
step={15}
|
||||
// timeslots={1}
|
||||
showMultiDayTimes
|
||||
localizer={localizer}
|
||||
min={bodyshop.schedule_start_time ? new Date(bodyshop.schedule_start_time) : new Date("2020-01-01T06:00:00")}
|
||||
|
||||
@@ -1,13 +1,384 @@
|
||||
import React, { createContext } from "react";
|
||||
import useSocket from "./useSocket"; // Import the custom hook
|
||||
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
|
||||
} from "../../graphql/notifications.queries.js";
|
||||
import { useMutation } from "@apollo/client";
|
||||
|
||||
// Create the SocketContext
|
||||
const SocketContext = createContext(null);
|
||||
|
||||
export const SocketProvider = ({ children, bodyshop }) => {
|
||||
const { socket, clientId } = useSocket(bodyshop);
|
||||
// This is how many notifications the database will populate on load, and the increment for load more
|
||||
export const INITIAL_NOTIFICATIONS = 10;
|
||||
|
||||
return <SocketContext.Provider value={{ socket, clientId }}> {children}</SocketContext.Provider>;
|
||||
export const SCENARIO_NOTIFICATION_LOCATION = "bottomRight";
|
||||
export const SCENARIO_NOTIFICATION_DURATION = 15; // Seconds
|
||||
|
||||
export const SocketProvider = ({ children, bodyshop, navigate }) => {
|
||||
const socketRef = useRef(null);
|
||||
const [clientId, setClientId] = useState(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const notification = useNotification();
|
||||
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||
|
||||
const [markNotificationRead] = useMutation(MARK_NOTIFICATION_READ, {
|
||||
update: (cache, { data: { update_notifications } }) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const updatedNotification = update_notifications.returning[0];
|
||||
|
||||
// Update the notifications list
|
||||
cache.modify({
|
||||
fields: {
|
||||
notifications(existing = [], { readField }) {
|
||||
return existing.map((notif) => {
|
||||
if (readField("id", notif) === updatedNotification.id) {
|
||||
return { ...notif, read: timestamp };
|
||||
}
|
||||
return notif;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update the unread count in notifications_aggregate
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error("MARK_NOTIFICATION_READ error in SocketProvider:", err);
|
||||
}
|
||||
});
|
||||
|
||||
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) => {
|
||||
if (readField("read", notif) === null && readField("associationid", notif) === userAssociationId) {
|
||||
return { ...notif, read: timestamp };
|
||||
}
|
||||
return 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
|
||||
)
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error("MARK_ALL_NOTIFICATIONS_READ error in SocketProvider:", 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) => {
|
||||
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)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 } }) {
|
||||
const isUnread = newNotification.read === null;
|
||||
const countChange = isUnread ? 1 : 0;
|
||||
return {
|
||||
...existing,
|
||||
aggregate: {
|
||||
...existing.aggregate,
|
||||
count: existing.aggregate.count + countChange
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
notification.info({
|
||||
message: `Changes for ${jobRoNumber}:`,
|
||||
description: (
|
||||
<ul
|
||||
className="notification-alert-unorderd-list"
|
||||
onClick={() => {
|
||||
markNotificationRead({ variables: { id: notificationId } })
|
||||
.then(() => navigate(`/manage/jobs/${jobId}`))
|
||||
.catch((e) => console.error(`Error marking notification read from info: ${e?.message || ""}`));
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{notifications.map((notif, index) => (
|
||||
<li className="notification-alert-unorderd-list-item" key={index}>
|
||||
{notif.body}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
placement: SCENARIO_NOTIFICATION_LOCATION,
|
||||
duration: SCENARIO_NOTIFICATION_DURATION
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Something went wrong handling a new notification: ${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("message", (message) => {
|
||||
try {
|
||||
if (typeof message === "string" && message.startsWith("42")) {
|
||||
const parsedMessage = JSON.parse(message.slice(2));
|
||||
const [event, data] = parsedMessage;
|
||||
if (event === "notification") handleNotification(data);
|
||||
} else if (Array.isArray(message)) {
|
||||
const [event, data] = message;
|
||||
if (event === "notification") handleNotification(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing socket message:", error);
|
||||
}
|
||||
});
|
||||
socketInstance.on("notification", handleNotification);
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider
|
||||
value={{
|
||||
socket: socketRef.current,
|
||||
clientId,
|
||||
isConnected,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSocket = () => {
|
||||
const context = useContext(SocketContext);
|
||||
if (!context) {
|
||||
throw new Error("useSocket must be used within a SocketProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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,9 @@ export const GET_JOB_BY_PK = gql`
|
||||
invoice_final_note
|
||||
iouparent
|
||||
job_totals
|
||||
job_watchers {
|
||||
user_email
|
||||
}
|
||||
joblines(where: { removed: { _eq: false } }, order_by: { line_no: asc }) {
|
||||
act_price
|
||||
act_price_before_ppc
|
||||
@@ -1890,6 +1893,7 @@ export const QUERY_JOB_CLOSE_DETAILS = gql`
|
||||
kmout
|
||||
qb_multiple_payers
|
||||
lbr_adjustments
|
||||
ownr_ea
|
||||
payments {
|
||||
amount
|
||||
created_at
|
||||
@@ -2566,3 +2570,30 @@ 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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
51
client/src/graphql/notifications.queries.js
Normal file
51
client/src/graphql/notifications.queries.js
Normal file
@@ -0,0 +1,51 @@
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
196
client/src/pages/jobs-detail/job-watcher-toggle.component.jsx
Normal file
196
client/src/pages/jobs-detail/job-watcher-toggle.component.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { EyeFilled, EyeOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../graphql/jobs.queries.js";
|
||||
import { Avatar, Button, Divider, List, Popover, Select, Tooltip, Typography } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
|
||||
import EmployeeSearchSelectComponent from "../../components/employee-search-select/employee-search-select.component.jsx";
|
||||
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component.jsx";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
const JobWatcherToggle = ({ job, currentUser, bodyshop }) => {
|
||||
const { t } = useTranslation();
|
||||
const userEmail = currentUser.email;
|
||||
const jobid = job.id;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedWatcher, setSelectedWatcher] = useState(null); // New state for selected value
|
||||
const [selectedTeam, setSelectedTeam] = useState(null); // New state to track selected team
|
||||
|
||||
// Fetch current watchers
|
||||
const { data: watcherData, loading: watcherLoading } = useQuery(GET_JOB_WATCHERS, { variables: { jobid } });
|
||||
|
||||
// Extract watchers list
|
||||
const jobWatchers = useMemo(() => watcherData?.job_watchers || [], [watcherData]);
|
||||
const isWatching = useMemo(() => jobWatchers.some((w) => w.user_email === userEmail), [jobWatchers, userEmail]);
|
||||
|
||||
// Add watcher mutation
|
||||
const [addWatcher, { loading: adding }] = useMutation(ADD_JOB_WATCHER, {
|
||||
refetchQueries: [{ query: GET_JOB_WATCHERS, variables: { jobid } }]
|
||||
});
|
||||
|
||||
// Remove watcher mutation
|
||||
const [removeWatcher, { loading: removing }] = useMutation(REMOVE_JOB_WATCHER, {
|
||||
refetchQueries: [{ query: GET_JOB_WATCHERS, variables: { jobid } }]
|
||||
});
|
||||
|
||||
// Toggle watcher for self
|
||||
const handleToggleSelf = useCallback(() => {
|
||||
(isWatching
|
||||
? removeWatcher({ variables: { jobid, userEmail } })
|
||||
: addWatcher({ variables: { jobid, userEmail } })
|
||||
).catch((err) => console.error(`Error updating job watcher: ${err.message}`));
|
||||
}, [isWatching, addWatcher, removeWatcher, jobid, userEmail]);
|
||||
|
||||
// Handle removing a watcher
|
||||
const handleRemoveWatcher = (userEmail) => {
|
||||
removeWatcher({ variables: { jobid, userEmail } }).catch((err) =>
|
||||
console.error(`Error removing job watcher: ${err.message}`)
|
||||
);
|
||||
};
|
||||
|
||||
const handleWatcherSelect = (selectedUser) => {
|
||||
const employee = bodyshop.employees.find((e) => e.id === selectedUser);
|
||||
if (!employee) return;
|
||||
|
||||
const isAlreadyWatching = jobWatchers.some((w) => w.user_email === employee.user_email);
|
||||
|
||||
if (isAlreadyWatching) {
|
||||
handleRemoveWatcher(employee.user_email);
|
||||
} else {
|
||||
addWatcher({ variables: { jobid, userEmail: employee.user_email } }).catch((err) =>
|
||||
console.error(`Error adding job watcher: ${err.message}`)
|
||||
);
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
setSelectedWatcher(null);
|
||||
};
|
||||
|
||||
const handleTeamSelect = (team) => {
|
||||
const selectedTeamMembers = JSON.parse(team); // Parse the array of emails
|
||||
|
||||
const newWatchers = selectedTeamMembers.filter(
|
||||
(email) => !jobWatchers.some((watcher) => watcher.user_email === email)
|
||||
);
|
||||
|
||||
// Add each new watcher
|
||||
newWatchers.forEach((email) => {
|
||||
addWatcher({ variables: { jobid, userEmail: email } }).catch((err) =>
|
||||
console.error(`Error adding job watcher: ${err.message}`)
|
||||
);
|
||||
});
|
||||
|
||||
// Clear selection
|
||||
setSelectedTeam(null);
|
||||
};
|
||||
|
||||
const handleRenderItem = (watcher) => {
|
||||
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="link" danger size="small" onClick={() => handleRemoveWatcher(watcher.user_email)}>
|
||||
{t("notifications.actions.remove")}
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar icon={<UserOutlined />} />}
|
||||
title={<Text>{displayName}</Text>}
|
||||
description={watcher.user_email} // Keep the email for reference
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
// Popover content
|
||||
const popoverContent = (
|
||||
<div style={{ width: 600 }}>
|
||||
{/* Self-toggle Button */}
|
||||
<Button
|
||||
block
|
||||
type="text"
|
||||
icon={isWatching ? <EyeOutlined /> : <EyeFilled />}
|
||||
onClick={handleToggleSelf}
|
||||
loading={adding || removing}
|
||||
>
|
||||
{isWatching ? t("notifications.tooltips.unwatch") : t("notifications.tooltips.watch")}
|
||||
</Button>
|
||||
{/* List of Watchers */}
|
||||
<Text type="secondary" style={{ marginBottom: 8, display: "block" }}>
|
||||
{t("notifications.labels.watching-issue")}
|
||||
</Text>
|
||||
{watcherLoading ? <LoadingSpinner /> : <List dataSource={jobWatchers} renderItem={handleRenderItem} />}
|
||||
{/* Employee Search Select (for adding watchers) */}
|
||||
<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))}
|
||||
placeholder={t("notifications.labels.employee-search")}
|
||||
value={selectedWatcher} // Controlled value
|
||||
onChange={(value) => {
|
||||
setSelectedWatcher(value); // Update selected state
|
||||
handleWatcherSelect(value); // Add watcher logic
|
||||
}}
|
||||
/>
|
||||
{/* Divider for UI separation */}
|
||||
{/* Only show team selection if there are available teams */}
|
||||
{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} // Controlled value
|
||||
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 ? employee.user_email : null;
|
||||
})
|
||||
.filter(Boolean); // Remove nulls
|
||||
|
||||
return {
|
||||
value: JSON.stringify(teamMembers), // Store array as string
|
||||
label: team.name // Use team name as label
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={popoverContent} trigger="click" open={open} onOpenChange={setOpen}>
|
||||
<Tooltip title={isWatching ? t("notifications.tooltips.unwatch") : t("notifications.tooltips.watch")}>
|
||||
<Button
|
||||
shape="circle"
|
||||
type={isWatching ? "primary" : "default"}
|
||||
icon={isWatching ? <EyeFilled /> : <EyeOutlined />}
|
||||
loading={watcherLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(JobWatcherToggle);
|
||||
@@ -56,6 +56,7 @@ import { DateTimeFormat } from "../../utils/DateFormatter";
|
||||
import dayjs from "../../utils/day";
|
||||
import UndefinedToNull from "../../utils/undefinedtonull";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import JobWatcherToggle from "./job-watcher-toggle.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -319,7 +320,13 @@ export function JobsDetailPage({
|
||||
>
|
||||
<PageHeader
|
||||
// onBack={() => window.history.back()}
|
||||
title={job.ro_number || t("general.labels.na")}
|
||||
|
||||
title={
|
||||
<Space>
|
||||
<JobWatcherToggle 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/socketContext.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
|
||||
|
||||
@@ -347,6 +347,9 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
|
||||
window.$zoho.salesiq.visitor.info({ "Shop Name": payload.shopname });
|
||||
}
|
||||
});
|
||||
payload.features?.allAccess === true
|
||||
? window.$crisp.push(["set", "session:segments", [["allAccess"]]])
|
||||
: window.$crisp.push(["set", "session:segments", [["basic"]]]);
|
||||
} catch (error) {
|
||||
console.error("Couldnt find $crisp.");
|
||||
}
|
||||
|
||||
@@ -689,7 +689,7 @@
|
||||
"notespresets": "Notes Presets",
|
||||
"orderstatuses": "Order Statuses",
|
||||
"partslocations": "Parts Locations",
|
||||
"partsscan": "Critical Parts Scanning",
|
||||
"partsscan": "Parts Scanning",
|
||||
"printlater": "Print Later",
|
||||
"qbo": "Use QuickBooks Online?",
|
||||
"qbo_departmentid": "QBO Department ID",
|
||||
@@ -3766,6 +3766,55 @@
|
||||
"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",
|
||||
"mark-all-read": "Mark Read",
|
||||
"loading": "Loading Notifications..."
|
||||
},
|
||||
"actions": {
|
||||
"remove": "remove"
|
||||
},
|
||||
"aria": {
|
||||
"toggle": "Toggle Watching Job"
|
||||
},
|
||||
"tooltips": {
|
||||
"watch": "Watch Job",
|
||||
"unwatch": "Unwatch Job"
|
||||
},
|
||||
"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,50 @@
|
||||
"validation": {
|
||||
"unique_vendor_name": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"labels": {
|
||||
"scenario": "",
|
||||
"notificationscenarios": "",
|
||||
"save": "",
|
||||
"watching-issue": "",
|
||||
"add-watchers": "",
|
||||
"employee-search": "",
|
||||
"teams-search": "",
|
||||
"add-watchers-team": ""
|
||||
},
|
||||
"actions": {
|
||||
"remove": ""
|
||||
},
|
||||
"aria": {
|
||||
"toggle": ""
|
||||
},
|
||||
"tooltips": {
|
||||
"watch": "",
|
||||
"unwatch": ""
|
||||
},
|
||||
"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,50 @@
|
||||
"validation": {
|
||||
"unique_vendor_name": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"labels": {
|
||||
"scenario": "",
|
||||
"notificationscenarios": "",
|
||||
"save": "",
|
||||
"watching-issue": "",
|
||||
"add-watchers": "",
|
||||
"employee-search": "",
|
||||
"teams-search": "",
|
||||
"add-watchers-team": ""
|
||||
},
|
||||
"actions": {
|
||||
"remove": ""
|
||||
},
|
||||
"aria": {
|
||||
"toggle": ""
|
||||
},
|
||||
"tooltips": {
|
||||
"watch": "",
|
||||
"unwatch": ""
|
||||
},
|
||||
"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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -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
|
||||
|
||||
@@ -697,12 +697,6 @@
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
body:
|
||||
action: transform
|
||||
template: |-
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
@@ -1958,6 +1952,27 @@
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: notifications_docuemtns
|
||||
definition:
|
||||
enable_manual: false
|
||||
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 +2861,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 +2882,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 +2898,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 +3234,29 @@
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: notifications_joblines
|
||||
definition:
|
||||
enable_manual: false
|
||||
insert:
|
||||
columns: '*'
|
||||
update:
|
||||
columns:
|
||||
- critical
|
||||
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/handleJobLinesChange'
|
||||
version: 1
|
||||
- table:
|
||||
name: joblines_status
|
||||
schema: public
|
||||
@@ -3369,6 +3403,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:
|
||||
@@ -4473,10 +4514,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}}\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 +4863,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 +4893,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 +5739,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 +6229,13 @@
|
||||
columns: '*'
|
||||
update:
|
||||
columns:
|
||||
- joblineid
|
||||
- assigned_to
|
||||
- partsorderid
|
||||
- completed
|
||||
- description
|
||||
- billid
|
||||
- priority
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
@@ -6131,12 +6245,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 +6421,6 @@
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
body:
|
||||
action: transform
|
||||
template: |-
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
|
||||
@@ -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";
|
||||
1287
package-lock.json
generated
1287
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
40
package.json
40
package.json
@@ -19,37 +19,39 @@
|
||||
"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.750.0",
|
||||
"@aws-sdk/client-elasticache": "^3.755.0",
|
||||
"@aws-sdk/client-s3": "^3.750.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.750.0",
|
||||
"@aws-sdk/client-ses": "^3.750.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.750.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.39.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",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -64,7 +66,7 @@
|
||||
"redis": "^4.7.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"skia-canvas": "^2.0.2",
|
||||
"soap": "^1.1.7",
|
||||
"soap": "^1.1.8",
|
||||
"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.2",
|
||||
"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
|
||||
|
||||
60
server.js
60
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"
|
||||
@@ -31,6 +31,8 @@ const { redisSocketEvents } = require("./server/web-sockets/redisSocketEvents");
|
||||
const { ElastiCacheClient, DescribeCacheClustersCommand } = require("@aws-sdk/client-elasticache");
|
||||
const { InstanceRegion } = require("./server/utils/instanceMgr");
|
||||
const StartStatusReporter = require("./server/utils/statusReporter");
|
||||
const { loadEmailQueue } = require("./server/notifications/queues/emailQueue");
|
||||
const { loadAppQueue } = require("./server/notifications/queues/appQueue");
|
||||
|
||||
const cleanupTasks = [];
|
||||
let isShuttingDown = false;
|
||||
@@ -58,7 +60,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",
|
||||
@@ -193,7 +195,15 @@ 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) => {
|
||||
@@ -222,14 +232,11 @@ const applySocketIO = async ({ server, app }) => {
|
||||
pubClient.on("error", (err) => logger.log(`Redis pubClient error: ${err}`, "ERROR", "redis"));
|
||||
subClient.on("error", (err) => logger.log(`Redis subClient error: ${err}`, "ERROR", "redis"));
|
||||
|
||||
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 +294,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>}
|
||||
@@ -304,6 +339,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 });
|
||||
@@ -321,7 +359,7 @@ const main = async () => {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -55,7 +55,7 @@ exports.default = async (req, res) => {
|
||||
const csv = converter.json2csv(shopList, { emptyFieldValue: "" });
|
||||
emailer
|
||||
.sendTaskEmail({
|
||||
to: ["patrick.fic@convenient-brands.com", "bradley.rhoades@convenient-brands.com"],
|
||||
to: ["patrick.fic@convenient-brands.com", "bradley.rhoades@convenient-brands.com", "jrome@rometech.com"],
|
||||
subject: `RO Usage Report - ${moment().format("MM/DD/YYYY")}`,
|
||||
text: `
|
||||
Usage Report for ${moment().format("MM/DD/YYYY")} for Rome Online Customers.
|
||||
|
||||
@@ -55,7 +55,7 @@ const sendServerEmail = async ({ subject, text }) => {
|
||||
imex: `ImEX Online API - ${process.env.NODE_ENV} <noreply@imex.online>`,
|
||||
rome: `Rome Online API - ${process.env.NODE_ENV} <noreply@romeonline.io>`
|
||||
}),
|
||||
to: ["patrick@imexsystems.ca", "support@thinkimex.com"],
|
||||
to: ["support@thinkimex.com"],
|
||||
subject: subject,
|
||||
text: text,
|
||||
ses: {
|
||||
@@ -69,11 +69,11 @@ 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 });
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.log("server-email-failure", "error", null, null, { error });
|
||||
logger.log("server-email-failure", "error", null, null, { message: error?.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,11 +92,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 || info });
|
||||
logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message });
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.log("server-email-failure", "error", null, null, { error });
|
||||
logger.log("server-email-failure", "error", null, null, { message: error?.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -125,7 +125,7 @@ const sendEmail = async (req, res) => {
|
||||
cc: req.body.cc,
|
||||
subject: req.body.subject,
|
||||
templateStrings: req.body.templateStrings,
|
||||
error
|
||||
errorMessage: error?.message
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -194,7 +194,7 @@ const sendEmail = async (req, res) => {
|
||||
cc: req.body.cc,
|
||||
subject: req.body.subject,
|
||||
templateStrings: req.body.templateStrings,
|
||||
error: err
|
||||
errorMessage: err?.message
|
||||
});
|
||||
logEmail(req, {
|
||||
to: req.body.to,
|
||||
@@ -202,7 +202,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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -239,24 +239,24 @@ const emailBounce = async (req, res) => {
|
||||
return;
|
||||
}
|
||||
//If it's bounced, log it as bounced in audit log. Send an email to the user.
|
||||
const result = await client.request(queries.UPDATE_EMAIL_AUDIT, {
|
||||
await client.request(queries.UPDATE_EMAIL_AUDIT, {
|
||||
sesid: messageId,
|
||||
status: "Bounced",
|
||||
context: message.bounce?.bouncedRecipients
|
||||
});
|
||||
mailer.sendMail(
|
||||
{
|
||||
from: InstanceMgr({
|
||||
from: InstanceManager({
|
||||
imex: `ImEX Online <noreply@imex.online>`,
|
||||
rome: `Rome Online <noreply@romeonline.io>`
|
||||
}),
|
||||
to: replyTo,
|
||||
//bcc: "patrick@snapt.ca",
|
||||
subject: `${InstanceMgr({
|
||||
subject: `${InstanceManager({
|
||||
imex: "ImEX Online",
|
||||
rome: "Rome Online"
|
||||
})} Bounced Email - RE: ${subject}`,
|
||||
text: `${InstanceMgr({
|
||||
text: `${InstanceManager({
|
||||
imex: "ImEX Online",
|
||||
rome: "Rome Online"
|
||||
})} has tried to deliver an email with the subject: ${subject} to the intended recipients but encountered an error.
|
||||
@@ -270,14 +270,14 @@ ${body.bounce?.bouncedRecipients.map(
|
||||
},
|
||||
(err, info) => {
|
||||
logger.log("sns-error", err ? "error" : "debug", "api", null, {
|
||||
message: err ? JSON.stringify(error) : info
|
||||
errorMessage: err?.message
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log("sns-error", "ERROR", "api", null, {
|
||||
error: JSON.stringify(error)
|
||||
errorMessage: error?.message
|
||||
});
|
||||
}
|
||||
res.sendStatus(200);
|
||||
|
||||
@@ -10,6 +10,7 @@ const generateEmailTemplate = require("./generateTemplate");
|
||||
const moment = require("moment-timezone");
|
||||
const { taskEmailQueue } = require("./tasksEmailsQueue");
|
||||
const mailer = require("./mailer");
|
||||
const { InstanceEndpoints } = require("../utils/instanceMgr");
|
||||
|
||||
// Initialize the Tasks Email Queue
|
||||
const tasksEmailQueue = taskEmailQueue();
|
||||
@@ -83,15 +84,8 @@ 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"}`,
|
||||
@@ -108,9 +102,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 +129,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<*>}
|
||||
@@ -186,7 +179,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<*>}
|
||||
@@ -264,11 +257,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 +266,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: ${formatPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a></li>`.trim()
|
||||
)
|
||||
.join("")}
|
||||
</ul>`
|
||||
@@ -338,6 +326,5 @@ const tasksRemindEmail = async (req, res) => {
|
||||
|
||||
module.exports = {
|
||||
taskAssignedEmail,
|
||||
tasksRemindEmail,
|
||||
getEndpoints
|
||||
tasksRemindEmail
|
||||
};
|
||||
|
||||
@@ -2705,3 +2705,57 @@ exports.INSERT_AUDIT_TRAIL = `
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
exports.GET_JOB_WATCHERS = `
|
||||
query GET_JOB_WATCHERS($jobid: uuid!) {
|
||||
job_watchers_aggregate(where: { jobid: { _eq: $jobid } }) {
|
||||
nodes {
|
||||
user_email
|
||||
user {
|
||||
authid
|
||||
employee {
|
||||
id
|
||||
first_name
|
||||
last_name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
job: jobs_by_pk(id: $jobid) {
|
||||
id
|
||||
ro_number
|
||||
clm_no
|
||||
bodyshop {
|
||||
id
|
||||
shopname
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
140
server/notifications/eventHandlers.js
Normal file
140
server/notifications/eventHandlers.js
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @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, { error: error?.message });
|
||||
});
|
||||
|
||||
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.
|
||||
*
|
||||
* @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) =>
|
||||
processNotificationEvent(req, res, "req.body.event.new.jobid", "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;
|
||||
88
server/notifications/eventParser.js
Normal file
88
server/notifications/eventParser.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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 optionally extracts a `jobId` based on a specified field. The result includes 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.jobIdField] - The field name used to extract the jobId (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 extracted jobId or null if not applicable.
|
||||
*/
|
||||
const eventParser = async ({ oldData, newData, trigger, table, jobIdField }) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract jobId if jobIdField is provided
|
||||
let jobId = null;
|
||||
if (jobIdField) {
|
||||
let keyName = jobIdField;
|
||||
const prefix = "req.body.event.new.";
|
||||
// Strip prefix if present to isolate the actual field name
|
||||
if (keyName.startsWith(prefix)) {
|
||||
keyName = keyName.slice(prefix.length);
|
||||
}
|
||||
// Look for jobId in newData first, then fallback to oldData if necessary
|
||||
jobId = newData[keyName] || (oldData && oldData[keyName]) || null;
|
||||
}
|
||||
|
||||
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 // Extracted jobId or null
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = eventParser;
|
||||
280
server/notifications/queues/appQueue.js
Normal file
280
server/notifications/queues/appQueue.js
Normal file
@@ -0,0 +1,280 @@
|
||||
const { Queue, Worker } = require("bullmq");
|
||||
const { INSERT_NOTIFICATIONS_MUTATION } = require("../../graphql-client/queries");
|
||||
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) ? 1 : Math.max(1, parsedValue); // Default to 1, 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.info("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.info(`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.info(`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.info(`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.warn(`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.info(
|
||||
`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.info(
|
||||
`Sent ${notifications.length} consolidated notifications to ${user} for jobId ${jobId} with notificationId ${notificationId}`
|
||||
);
|
||||
} else {
|
||||
logger.logger.warn(`No socket IDs found for ${user} in bodyShopId ${bodyShopId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await pubClient.del(`app:consolidate:${jobId}`);
|
||||
} catch (err) {
|
||||
logger.logger.error(`Consolidation error for jobId ${jobId}: ${err.message}`, { error: err });
|
||||
throw err;
|
||||
} finally {
|
||||
await pubClient.del(lockKey);
|
||||
}
|
||||
} else {
|
||||
logger.logger.info(`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.info(`Add job ${job.id} completed`));
|
||||
consolidateWorker.on("completed", (job) => logger.logger.info(`Consolidate job ${job.id} completed`));
|
||||
addWorker.on("failed", (job, err) =>
|
||||
logger.logger.error(`Add job ${job.id} failed: ${err.message}`, { error: err })
|
||||
);
|
||||
consolidateWorker.on("failed", (job, err) =>
|
||||
logger.logger.error(`Consolidate job ${job.id} failed: ${err.message}`, { error: err })
|
||||
);
|
||||
|
||||
const shutdown = async () => {
|
||||
logger.logger.info("Closing app queue workers...");
|
||||
await Promise.all([addWorker.close(), consolidateWorker.close()]);
|
||||
logger.logger.info("App queue workers closed");
|
||||
};
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", 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.info(`Added notification to queue for jobId ${jobId} with ${recipients.length} recipients`);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { loadAppQueue, getQueue, dispatchAppsToQueue };
|
||||
233
server/notifications/queues/emailQueue.js
Normal file
233
server/notifications/queues/emailQueue.js
Normal file
@@ -0,0 +1,233 @@
|
||||
const { Queue, Worker } = require("bullmq");
|
||||
const { sendTaskEmail } = require("../../email/sendemail");
|
||||
const generateEmailTemplate = require("../../email/generateTemplate");
|
||||
const { InstanceEndpoints } = require("../../utils/instanceMgr");
|
||||
|
||||
const EMAIL_CONSOLIDATION_DELAY_IN_MINS = (() => {
|
||||
const envValue = process.env?.APP_CONSOLIDATION_DELAY_IN_MINS;
|
||||
const parsedValue = envValue ? parseInt(envValue, 10) : NaN;
|
||||
return isNaN(parsedValue) ? 1 : Math.max(1, parsedValue); // Default to 1, 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.info("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.info(`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); // Set expiration
|
||||
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); // Set expiration
|
||||
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, // Retry up to 3 times
|
||||
backoff: LOCK_EXPIRATION // Retry delay matches lock expiration (15s)
|
||||
}
|
||||
);
|
||||
logger.logger.info(`Scheduled email consolidation for jobId ${jobId}`);
|
||||
await pubClient.expire(consolidateKey, CONSOLIDATION_KEY_EXPIRATION / 1000); // Convert to seconds
|
||||
} 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.info(`Consolidating emails for jobId ${jobId}`);
|
||||
|
||||
const lockKey = `lock:emailConsolidate:${jobId}`;
|
||||
const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", LOCK_EXPIRATION / 1000); // Convert to seconds
|
||||
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} at ${bodyShopName}`;
|
||||
// Use the template instead of inline HTML
|
||||
const emailBody = generateEmailTemplate({
|
||||
header: `${multipleUpdateString} for Job ${jobRoNumber}`,
|
||||
subHeader: `Dear ${firstName},`,
|
||||
body: `
|
||||
<p>There have been updates to job ${jobRoNumber} 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.info(
|
||||
`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.logger.error(`Email consolidation error for jobId ${jobId}: ${err.message}`, { error: err });
|
||||
throw err; // Trigger retry if attempts remain
|
||||
} finally {
|
||||
await pubClient.del(lockKey);
|
||||
}
|
||||
} else {
|
||||
logger.logger.info(`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.info(`Email add job ${job.id} completed`));
|
||||
emailConsolidateWorker.on("completed", (job) => logger.logger.info(`Email consolidate job ${job.id} completed`));
|
||||
emailAddWorker.on("failed", (job, err) =>
|
||||
logger.logger.error(`Email add job ${job.id} failed: ${err.message}`, { error: err })
|
||||
);
|
||||
emailConsolidateWorker.on("failed", (job, err) =>
|
||||
logger.logger.error(`Email consolidate job ${job.id} failed: ${err.message}`, { error: err })
|
||||
);
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
logger.logger.info("Closing email queue workers...");
|
||||
await Promise.all([emailAddWorker.close(), emailConsolidateWorker.close()]);
|
||||
logger.logger.info("Email queue workers closed");
|
||||
};
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", 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}, 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.info(`Added email notification to queue for jobId ${jobId} with ${recipients.length} recipients`);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { loadEmailQueue, getQueue, dispatchEmailsToQueue };
|
||||
505
server/notifications/scenarioBuilders.js
Normal file
505
server/notifications/scenarioBuilders.js
Normal file
@@ -0,0 +1,505 @@
|
||||
const { getJobAssignmentType } = require("./stringHelpers");
|
||||
|
||||
/**
|
||||
* 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 transport status has been updated from ${data?.changedFields?.altTransport?.old}.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
bodyShopId: data.bodyShopId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
key: "notifications.job.alternateTransportChanged",
|
||||
body, // Same as email 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 body = `A bill of $${data.data.clm_total} has been posted.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.billPosted",
|
||||
body,
|
||||
variables: {
|
||||
clmTotal: data.data.clm_total
|
||||
},
|
||||
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 critical parts status has changed to ${data.data.queued_for_parts ? "queued" : "not queued"}.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
bodyShopId: data.bodyShopId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
key: "notifications.job.criticalPartsStatusChanged",
|
||||
body,
|
||||
variables: {
|
||||
queuedForParts: data.data.queued_for_parts,
|
||||
oldQueuedForParts: data.changedFields.queued_for_parts?.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 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 has been added to 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} to ${data.changedFields.status.new}`;
|
||||
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) => {
|
||||
const body = `New media has been added.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.newMediaAdded",
|
||||
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 new notes added to a job.
|
||||
*/
|
||||
const newNoteAddedBuilder = (data) => {
|
||||
const body = `A new note has been added: "${data.data.text}"`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.newNoteAdded",
|
||||
body,
|
||||
variables: {
|
||||
text: data.data.text
|
||||
},
|
||||
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 body = `A new time ticket has been posted.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.newTimeTicketPosted",
|
||||
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 parts marked as back-ordered.
|
||||
*/
|
||||
const partMarkedBackOrderedBuilder = (data) => {
|
||||
const body = `A part has been marked as back-ordered.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.partBackOrdered",
|
||||
body,
|
||||
variables: {
|
||||
queuedForParts: data.changedFields.queued_for_parts?.new,
|
||||
oldQueuedForParts: data.changedFields.queued_for_parts?.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 payment collection events.
|
||||
*/
|
||||
const paymentCollectedCompletedBuilder = (data) => {
|
||||
const body = `Payment of $${data.data.clm_total} has been collected.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.paymentCollected",
|
||||
body,
|
||||
variables: {
|
||||
clmTotal: data.data.clm_total
|
||||
},
|
||||
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 body = `Scheduled dates have been updated.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.scheduledDatesChanged",
|
||||
body,
|
||||
variables: {
|
||||
scheduledIn: data.changedFields.scheduled_in?.new,
|
||||
oldScheduledIn: data.changedFields.scheduled_in?.old,
|
||||
scheduledCompletion: data.changedFields.scheduled_completion?.new,
|
||||
oldScheduledCompletion: data.changedFields.scheduled_completion?.old,
|
||||
scheduledDelivery: data.changedFields.scheduled_delivery?.new,
|
||||
oldScheduledDelivery: data.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 supplement imported events.
|
||||
*/
|
||||
const supplementImportedBuilder = (data) => {
|
||||
const body = `A supplement of $${data.data.cieca_ttl?.data?.supp_amt || 0} has been imported.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: "notifications.job.supplementImported",
|
||||
body,
|
||||
variables: {
|
||||
suppAmt: data.data.cieca_ttl?.data?.supp_amt
|
||||
},
|
||||
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 body = `Tasks have been ${data.isNew ? "created" : "updated"}.`;
|
||||
const result = {
|
||||
app: {
|
||||
jobId: data.jobId,
|
||||
jobRoNumber: data.jobRoNumber,
|
||||
bodyShopId: data.bodyShopId,
|
||||
key: data.isNew ? "notifications.job.taskCreated" : "notifications.job.taskUpdated",
|
||||
body,
|
||||
variables: {
|
||||
isNew: data.isNew,
|
||||
roNumber: data.jobRoNumber
|
||||
},
|
||||
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
|
||||
};
|
||||
192
server/notifications/scenarioMapperr.js
Normal file
192
server/notifications/scenarioMapperr.js
Normal file
@@ -0,0 +1,192 @@
|
||||
const {
|
||||
jobAssignedToMeBuilder,
|
||||
billPostedHandler,
|
||||
newNoteAddedBuilder,
|
||||
scheduledDatesChangedBuilder,
|
||||
tasksUpdatedCreatedBuilder,
|
||||
jobStatusChangeBuilder,
|
||||
jobsAddedToProductionBuilder,
|
||||
alternateTransportChangedBuilder,
|
||||
newTimeTicketPostedBuilder,
|
||||
intakeDeliveryChecklistCompletedBuilder,
|
||||
paymentCollectedCompletedBuilder,
|
||||
newMediaAddedReassignedBuilder,
|
||||
criticalPartsStatusChangedBuilder,
|
||||
supplementImportedBuilder,
|
||||
partMarkedBackOrderedBuilder
|
||||
} = require("./scenarioBuilders");
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* - onlyTrue {Array<string>}: Specifies fields that must be true for the scenario to match.
|
||||
* - builder {Function}: A function to handle the scenario.
|
||||
*/
|
||||
const notificationScenarios = [
|
||||
{
|
||||
key: "job-assigned-to-me",
|
||||
table: "jobs",
|
||||
fields: ["employee_pre", "employee_body", "employee_csr", "employee_refinish"],
|
||||
matchToUserFields: ["employee_pre", "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"],
|
||||
builder: jobsAddedToProductionBuilder
|
||||
},
|
||||
{
|
||||
key: "alternate-transport-changed",
|
||||
table: "jobs",
|
||||
fields: ["alt_transport"],
|
||||
builder: alternateTransportChangedBuilder
|
||||
},
|
||||
{
|
||||
key: "new-time-ticket-posted",
|
||||
table: "timetickets",
|
||||
builder: newTimeTicketPostedBuilder
|
||||
},
|
||||
{
|
||||
// Good test for batching as this will hit multiple scenarios
|
||||
key: "intake-delivery-checklist-completed",
|
||||
table: "jobs",
|
||||
fields: ["intakechecklist"],
|
||||
builder: intakeDeliveryChecklistCompletedBuilder
|
||||
},
|
||||
{
|
||||
key: "payment-collected-completed",
|
||||
table: "payments",
|
||||
onNew: true,
|
||||
builder: paymentCollectedCompletedBuilder
|
||||
},
|
||||
{
|
||||
// MAKE SURE YOU ARE NOT ON A LMS ENVIRONMENT
|
||||
// Potential Callbacks / Save for last
|
||||
// Not question mark for Non LMS Scenario
|
||||
key: "new-media-added-reassigned",
|
||||
table: "documents",
|
||||
fields: ["jobid"],
|
||||
builder: newMediaAddedReassignedBuilder
|
||||
},
|
||||
{
|
||||
key: "critical-parts-status-changed",
|
||||
table: "joblines",
|
||||
fields: ["critical"],
|
||||
onlyTrue: ["critical"],
|
||||
builder: criticalPartsStatusChangedBuilder
|
||||
},
|
||||
// -------------- Difficult ---------------
|
||||
// Holding off on this one for now
|
||||
{
|
||||
key: "supplement-imported",
|
||||
builder: supplementImportedBuilder
|
||||
// spans multiple tables,
|
||||
},
|
||||
// This one may be tricky as the jobid is not directly in the event data (this is probably wrong)
|
||||
// (should otherwise)
|
||||
// Status needs to mark meta data 'md_backorderd' for example
|
||||
// Double check Jobid
|
||||
{
|
||||
key: "part-marked-back-ordered",
|
||||
table: "joblines",
|
||||
builder: partMarkedBackOrderedBuilder
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns an array of scenarios that match the given event data.
|
||||
*
|
||||
* @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.)
|
||||
*
|
||||
* @returns {Array<Object>} An array of matching scenario objects.
|
||||
*/
|
||||
const getMatchingScenarios = (eventData) =>
|
||||
notificationScenarios.filter((scenario) => {
|
||||
// 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) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) return false;
|
||||
} else {
|
||||
if (eventData.isNew !== scenario.onNew) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// OnlyTrue logic:
|
||||
// If a scenario defines an onlyTrue array, then at least one of those fields must have changed
|
||||
// and its new value (from eventData.data) must be non-falsey.
|
||||
if (scenario.onlyTrue && Array.isArray(scenario.onlyTrue) && scenario.onlyTrue.length > 0) {
|
||||
const hasTruthyChange = scenario.onlyTrue.some(
|
||||
(field) => eventData.changedFieldNames.includes(field) && Boolean(eventData.data[field])
|
||||
);
|
||||
if (!hasTruthyChange) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
notificationScenarios,
|
||||
getMatchingScenarios
|
||||
};
|
||||
241
server/notifications/scenarioParser.js
Normal file
241
server/notifications/scenarioParser.js
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @module scenarioParser
|
||||
* @description
|
||||
* This module exports a function that parses an event and triggers notification scenarios based on the event data.
|
||||
* It integrates with event parsing utilities, GraphQL queries, and notification queues to manage the dispatching
|
||||
* of notifications via email and app channels. The function processes event data, identifies relevant scenarios,
|
||||
* queries user notification preferences, and dispatches notifications accordingly.
|
||||
*/
|
||||
|
||||
const eventParser = require("./eventParser");
|
||||
const { client: gqlClient } = require("../graphql-client/graphql-client");
|
||||
const queries = require("../graphql-client/queries");
|
||||
const { isEmpty, isFunction } = require("lodash");
|
||||
const { getMatchingScenarios } = require("./scenarioMapperr");
|
||||
const { dispatchEmailsToQueue } = require("./queues/emailQueue");
|
||||
const { dispatchAppsToQueue } = require("./queues/appQueue");
|
||||
|
||||
// If true, the user who commits the action will NOT receive notifications; if false, they will.
|
||||
const FILTER_SELF_FROM_WATCHERS = (() => process.env.NODE_ENV === "production")();
|
||||
|
||||
/**
|
||||
* Parses an event and determines matching scenarios for notifications.
|
||||
* Queries job watchers and notification settings before triggering scenario builders.
|
||||
*
|
||||
* @param {Object} req - The request object containing event data, trigger, table, and logger.
|
||||
* @param {string} jobIdField - The field name used to extract the job ID from the event data.
|
||||
* @returns {Promise<void>} Resolves when the parsing and notification dispatching process is complete.
|
||||
* @throws {Error} If required request fields (event data, trigger, or table) or body shop data are missing.
|
||||
*/
|
||||
const scenarioParser = async (req, jobIdField) => {
|
||||
const { event, trigger, table } = req.body;
|
||||
const { logger } = req;
|
||||
|
||||
// Validate we know what user committed the action that fired the parser
|
||||
const hasuraUserId = event?.session_variables?.["x-hasura-user-id"];
|
||||
|
||||
// Bail if we don't know
|
||||
if (!hasuraUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that required fields are present in the request body
|
||||
if (!event?.data || !trigger || !table) {
|
||||
throw new Error("Missing required request fields: event data, trigger, or table.");
|
||||
}
|
||||
|
||||
// Step 1: Parse the event data to extract details like job ID and changed fields
|
||||
const eventData = await eventParser({
|
||||
newData: event.data.new,
|
||||
oldData: event.data.old,
|
||||
trigger,
|
||||
table,
|
||||
jobIdField
|
||||
});
|
||||
|
||||
// Step 2: Query job watchers associated with the job ID using GraphQL
|
||||
const watcherData = await gqlClient.request(queries.GET_JOB_WATCHERS, {
|
||||
jobid: eventData.jobId
|
||||
});
|
||||
|
||||
// Transform watcher data into a simplified format with email and employee details
|
||||
let jobWatchers = watcherData?.job_watchers_aggregate?.nodes?.map((watcher) => ({
|
||||
email: watcher.user_email,
|
||||
firstName: watcher?.user?.employee?.first_name,
|
||||
lastName: watcher?.user?.employee?.last_name,
|
||||
employeeId: watcher?.user?.employee?.id,
|
||||
authId: watcher?.user?.authid
|
||||
}));
|
||||
|
||||
if (FILTER_SELF_FROM_WATCHERS) {
|
||||
jobWatchers = jobWatchers.filter((watcher) => watcher.authId !== hasuraUserId);
|
||||
}
|
||||
|
||||
// Exit early if no job watchers are found for this job
|
||||
if (isEmpty(jobWatchers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Extract body shop information from the job data
|
||||
const bodyShopId = watcherData?.job?.bodyshop?.id;
|
||||
const bodyShopName = watcherData?.job?.bodyshop?.shopname;
|
||||
const jobRoNumber = watcherData?.job?.ro_number;
|
||||
const jobClaimNumber = watcherData?.job?.clm_no;
|
||||
|
||||
// Validate that body shop data exists, as it’s required for notifications
|
||||
if (!bodyShopId || !bodyShopName) {
|
||||
throw new Error("No bodyshop data found for this job.");
|
||||
}
|
||||
|
||||
// Step 4: Identify scenarios that match the event data and job context
|
||||
const matchingScenarios = getMatchingScenarios({
|
||||
...eventData,
|
||||
jobWatchers,
|
||||
bodyShopId,
|
||||
bodyShopName
|
||||
});
|
||||
|
||||
// Exit early if no matching scenarios are identified
|
||||
if (isEmpty(matchingScenarios)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine event data with additional context for scenario processing
|
||||
const finalScenarioData = {
|
||||
...eventData,
|
||||
jobWatchers,
|
||||
bodyShopId,
|
||||
bodyShopName,
|
||||
matchingScenarios
|
||||
};
|
||||
|
||||
// Step 5: Query notification settings for the job watchers
|
||||
const associationsData = await gqlClient.request(queries.GET_NOTIFICATION_ASSOCIATIONS, {
|
||||
emails: jobWatchers.map((x) => x.email),
|
||||
shopid: bodyShopId
|
||||
});
|
||||
|
||||
// Exit early if no notification associations are found
|
||||
if (isEmpty(associationsData?.associations)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 6: Filter scenario watchers based on their enabled notification methods
|
||||
finalScenarioData.matchingScenarios = finalScenarioData.matchingScenarios.map((scenario) => ({
|
||||
...scenario,
|
||||
scenarioWatchers: associationsData.associations
|
||||
.filter((assoc) => {
|
||||
const settings = assoc.notification_settings && assoc.notification_settings[scenario.key];
|
||||
// Include only watchers with at least one enabled notification method (app, email, or FCM)
|
||||
return settings && (settings.app || settings.email || settings.fcm);
|
||||
})
|
||||
.map((assoc) => {
|
||||
const settings = assoc.notification_settings[scenario.key];
|
||||
const watcherEmail = assoc.useremail;
|
||||
const matchingWatcher = jobWatchers.find((watcher) => watcher.email === watcherEmail);
|
||||
|
||||
// Build watcher object with notification preferences and personal details
|
||||
return {
|
||||
user: watcherEmail,
|
||||
email: settings.email,
|
||||
app: settings.app,
|
||||
fcm: settings.fcm,
|
||||
firstName: matchingWatcher?.firstName,
|
||||
lastName: matchingWatcher?.lastName,
|
||||
employeeId: matchingWatcher?.employeeId,
|
||||
associationId: assoc.id
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
// Exit early if no scenarios have eligible watchers after filtering
|
||||
if (isEmpty(finalScenarioData?.matchingScenarios)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 7: Build and collect scenarios to dispatch notifications for
|
||||
const scenariosToDispatch = [];
|
||||
|
||||
for (const scenario of finalScenarioData.matchingScenarios) {
|
||||
// Skip if no watchers or no builder function is defined for the scenario
|
||||
if (isEmpty(scenario.scenarioWatchers) || !isFunction(scenario.builder)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let eligibleWatchers = scenario.scenarioWatchers;
|
||||
|
||||
// Filter watchers to only those assigned to changed fields, if specified
|
||||
if (scenario.matchToUserFields && scenario.matchToUserFields.length > 0) {
|
||||
eligibleWatchers = scenario.scenarioWatchers.filter((watcher) =>
|
||||
scenario.matchToUserFields.some(
|
||||
(field) => eventData.changedFieldNames.includes(field) && eventData.data[field]?.includes(watcher.employeeId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Skip if no watchers remain after filtering
|
||||
if (isEmpty(eligibleWatchers)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 8: Filter scenario fields to include only those that changed
|
||||
const filteredScenarioFields =
|
||||
scenario.fields?.filter((field) => eventData.changedFieldNames.includes(field)) || [];
|
||||
|
||||
// Use the scenario’s builder to construct the notification data
|
||||
scenariosToDispatch.push(
|
||||
scenario.builder({
|
||||
trigger: finalScenarioData.trigger.name,
|
||||
bodyShopId: finalScenarioData.bodyShopId,
|
||||
bodyShopName: finalScenarioData.bodyShopName,
|
||||
scenarioKey: scenario.key,
|
||||
scenarioTable: scenario.table,
|
||||
scenarioFields: filteredScenarioFields,
|
||||
scenarioBuilder: scenario.builder,
|
||||
scenarioWatchers: eligibleWatchers,
|
||||
jobId: finalScenarioData.jobId,
|
||||
jobRoNumber: jobRoNumber,
|
||||
jobClaimNumber: jobClaimNumber,
|
||||
isNew: finalScenarioData.isNew,
|
||||
changedFieldNames: finalScenarioData.changedFieldNames,
|
||||
changedFields: finalScenarioData.changedFields,
|
||||
data: finalScenarioData.data
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Exit early if no scenarios are ready to dispatch
|
||||
if (isEmpty(scenariosToDispatch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 9: Dispatch email notifications to the email queue
|
||||
const emailsToDispatch = scenariosToDispatch.map((scenario) => scenario?.email);
|
||||
if (!isEmpty(emailsToDispatch)) {
|
||||
dispatchEmailsToQueue({
|
||||
emailsToDispatch,
|
||||
logger
|
||||
}).catch((e) =>
|
||||
// Log any errors encountered during email dispatching
|
||||
logger.log("Something went wrong dispatching emails to the Email Notification Queue", "error", "queue", null, {
|
||||
message: e?.message
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Step 10: Dispatch app notifications to the app queue
|
||||
const appsToDispatch = scenariosToDispatch.map((scenario) => scenario?.app);
|
||||
if (!isEmpty(appsToDispatch)) {
|
||||
dispatchAppsToQueue({
|
||||
appsToDispatch,
|
||||
logger
|
||||
}).catch((e) =>
|
||||
// Log any errors encountered during app notification dispatching
|
||||
logger.log("Something went wrong dispatching apps to the App Notification Queue", "error", "queue", null, {
|
||||
message: e?.message
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = scenarioParser;
|
||||
31
server/notifications/stringHelpers.js
Normal file
31
server/notifications/stringHelpers.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @module jobAssignmentHelper
|
||||
* @description
|
||||
* This module provides utility functions for handling job assignment types.
|
||||
* Currently, it includes a function to map lowercase job assignment codes to their corresponding human-readable job types.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Maps a lowercase job assignment code to its corresponding human-readable job type.
|
||||
*
|
||||
* @param {string} data - The lowercase job assignment code (e.g., "employee_pre").
|
||||
* @returns {string} The human-readable job type (e.g., "Prep"). Returns an empty string if the code is unknown or if the input is null/undefined.
|
||||
*/
|
||||
const getJobAssignmentType = (data) => {
|
||||
switch (data) {
|
||||
case "employee_pre":
|
||||
return "Prep";
|
||||
case "employee_body":
|
||||
return "Body";
|
||||
case "employee_csr":
|
||||
return "CSR";
|
||||
case "employee_refinish":
|
||||
return "Refinish";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getJobAssignmentType
|
||||
};
|
||||
@@ -99,6 +99,7 @@ async function OpenSearchUpdateHandler(req, res) {
|
||||
break;
|
||||
case "payments":
|
||||
//Query to get the job and RO number
|
||||
|
||||
const payment = await client.request(
|
||||
`query ADMIN_GET_PAYMENT_BY_ID($paymentId: uuid!) {
|
||||
payments_by_pk(id: $paymentId) {
|
||||
@@ -147,11 +148,25 @@ async function OpenSearchUpdateHandler(req, res) {
|
||||
body: document
|
||||
};
|
||||
|
||||
logger.log("os-handler", "DEBUG", null, null, {
|
||||
id: req.body.event.data.new.id,
|
||||
index: req.body.table.name,
|
||||
bodyshopid: payload.body.bodyshopid
|
||||
// body: document
|
||||
});
|
||||
|
||||
const response = await osClient.index(payload);
|
||||
//console.log(response.body);
|
||||
res.status(200).json(response.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log("os-handler-error", "ERROR", null, null, {
|
||||
id: req.body.event.data.new.id,
|
||||
index: req.body.table.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
// body: document
|
||||
});
|
||||
res.status(400).json(JSON.stringify(error));
|
||||
}
|
||||
}
|
||||
@@ -166,7 +181,9 @@ async function OpenSearchSearchHandler(req, res) {
|
||||
}
|
||||
|
||||
logger.log("os-search", "DEBUG", req.user.email, null, {
|
||||
search
|
||||
search,
|
||||
index,
|
||||
bodyshopid
|
||||
});
|
||||
|
||||
const BearerToken = req.BearerToken;
|
||||
@@ -187,7 +204,7 @@ async function OpenSearchSearchHandler(req, res) {
|
||||
: process.env.BODY_SHOP_ID_MATCH_OVERRIDE;
|
||||
|
||||
const { body } = await osClient.search({
|
||||
...(index ? { index } : {}),
|
||||
...(index ? { index } : { index: ["jobs", "vehicles", "owners", "bills", "payments"] }),
|
||||
body: {
|
||||
size: 100,
|
||||
query: {
|
||||
@@ -231,8 +248,8 @@ async function OpenSearchSearchHandler(req, res) {
|
||||
"*ownr_fn^8",
|
||||
"*ownr_co_nm^8",
|
||||
"*ownr_ph1^8",
|
||||
"*ownr_ph2^8",
|
||||
"*"
|
||||
"*ownr_ph2^8"
|
||||
// "*"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,18 @@ const express = require("express");
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const { subscribe, unsubscribe, sendNotification } = require("../firebase/firebase-handler");
|
||||
const eventAuthorizationMiddleware = require("../middleware/eventAuthorizationMIddleware");
|
||||
|
||||
const handlePartsOrderChange = require("../notifications/eventHandlers/handlePartsOrderChange");
|
||||
const handlePartsDispatchChange = require("../notifications/eventHandlers/handlePartsDispatchChange");
|
||||
const handleTasksChange = require("../notifications/eventHandlers/handleTasksChange");
|
||||
const handleTimeTicketsChange = require("../notifications/eventHandlers/handleTimeTicketsChange");
|
||||
const handleJobsChange = require("../notifications/eventHandlers/handeJobsChange");
|
||||
const handleBillsChange = require("../notifications/eventHandlers/handleBillsChange");
|
||||
const {
|
||||
handleJobsChange,
|
||||
handleBillsChange,
|
||||
handlePartsOrderChange,
|
||||
handlePartsDispatchChange,
|
||||
handleTasksChange,
|
||||
handleTimeTicketsChange,
|
||||
handleNotesChange,
|
||||
handlePaymentsChange,
|
||||
handleDocumentsChange,
|
||||
handleJobLinesChange
|
||||
} = require("../notifications/eventHandlers");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -24,5 +29,9 @@ router.post("/events/handlePartsOrderChange", eventAuthorizationMiddleware, hand
|
||||
router.post("/events/handlePartsDispatchChange", eventAuthorizationMiddleware, handlePartsDispatchChange);
|
||||
router.post("/events/handleTasksChange", eventAuthorizationMiddleware, handleTasksChange);
|
||||
router.post("/events/handleTimeTicketsChange", eventAuthorizationMiddleware, handleTimeTicketsChange);
|
||||
router.post("/events/handleNotesChange", eventAuthorizationMiddleware, handleNotesChange);
|
||||
router.post("/events/handlePaymentsChange", eventAuthorizationMiddleware, handlePaymentsChange);
|
||||
router.post("/events/handleDocumentsChange", eventAuthorizationMiddleware, handleDocumentsChange);
|
||||
router.post("/events/handleJobLinesChange", eventAuthorizationMiddleware, handleJobLinesChange);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
7
server/utils/consoleDir.js
Normal file
7
server/utils/consoleDir.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const { inspect } = require("node:util");
|
||||
|
||||
const consoleDir = (data) => {
|
||||
console.log(inspect(data, { showHidden: false, depth: null, colors: true }));
|
||||
};
|
||||
|
||||
module.exports = consoleDir;
|
||||
@@ -58,4 +58,20 @@ exports.InstanceRegion = () =>
|
||||
rome: "us-east-2"
|
||||
});
|
||||
|
||||
exports.InstanceEndpoints = () =>
|
||||
InstanceManager({
|
||||
imex:
|
||||
process.env?.NODE_ENV === "development"
|
||||
? "https://localhost:3000"
|
||||
: process.env?.NODE_ENV === "test"
|
||||
? "https://test.imex.online"
|
||||
: "https://imex.online",
|
||||
rome:
|
||||
process.env?.NODE_ENV === "development"
|
||||
? "https://localhost:3000"
|
||||
: process.env?.NODE_ENV === "test"
|
||||
? "https://test.romeonline.io"
|
||||
: "https://romeonline.io"
|
||||
});
|
||||
|
||||
exports.default = InstanceManager;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user