From 56d50b855be6ceb61325304f01e29cd1c3020037 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Fri, 12 Dec 2025 19:39:02 -0800 Subject: [PATCH 01/32] IO-3464 S3 Document Editor Signed-off-by: Allan Carr --- .../document-editor.component.jsx | 64 ++++++++++++-- server/media/imgproxy-media.js | 87 +++++++++++++++++-- server/routes/mediaRoutes.js | 2 + 3 files changed, 140 insertions(+), 13 deletions(-) diff --git a/client/src/components/document-editor/document-editor.component.jsx b/client/src/components/document-editor/document-editor.component.jsx index f27875efe..8df1be57f 100644 --- a/client/src/components/document-editor/document-editor.component.jsx +++ b/client/src/components/document-editor/document-editor.component.jsx @@ -1,4 +1,5 @@ //import "tui-image-editor/dist/tui-image-editor.css"; +import axios from "axios"; import { Result } from "antd"; import * as markerjs2 from "markerjs2"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -6,8 +7,7 @@ import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; -import { handleUpload } from "../documents-upload/documents-upload.utility"; -import { GenerateSrcUrl } from "../jobs-documents-gallery/job-documents.utility"; +import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js"; import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; @@ -23,6 +23,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { const imgRef = useRef(null); const [loading, setLoading] = useState(false); const [uploaded, setuploaded] = useState(false); + const [imageUrl, setImageUrl] = useState(null); + const [imageLoaded, setImageLoaded] = useState(false); + const [imageLoading, setImageLoading] = useState(true); const markerArea = useRef(null); const { t } = useTranslation(); const notification = useNotification(); @@ -55,7 +58,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { ); useEffect(() => { - if (imgRef.current !== null) { + if (imgRef.current !== null && imageLoaded && !markerArea.current) { // create a marker.js MarkerArea markerArea.current = new markerjs2.MarkerArea(imgRef.current); @@ -78,7 +81,47 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { //markerArea.current.settings.displayMode = "inline"; markerArea.current.show(); } - }, [triggerUpload]); + }, [triggerUpload, imageLoaded]); + + useEffect(() => { + // When the document changes, fetch the image via axios so auth and base URL are applied + let isCancelled = false; + + const loadImage = async () => { + if (!document || !document.id) return; + + setImageLoaded(false); + setImageLoading(true); + + try { + const response = await axios.post( + "/media/imgproxy/original", + { documentId: document.id }, + { responseType: "blob" } + ); + + if (isCancelled) return; + + const blobUrl = URL.createObjectURL(response.data); + setImageUrl((prevUrl) => { + if (prevUrl) URL.revokeObjectURL(prevUrl); + return blobUrl; + }); + } catch (error) { + console.error("Failed to fetch original image blob", error); + } finally { + if (!isCancelled) { + setImageLoading(false); + } + } + }; + + loadImage(); + + return () => { + isCancelled = true; + }; + }, [document]); async function b64toBlob(url) { const res = await fetch(url); @@ -87,16 +130,21 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { return (
- {!loading && !uploaded && ( + {!loading && !uploaded && imageUrl && ( sample setImageLoaded(true)} + onError={(error) => { + console.error("Failed to load original image", error); + }} style={{ maxWidth: "90vw", maxHeight: "90vh" }} /> )} - {loading && } + {(loading || imageLoading || !imageLoaded) && !uploaded && ( + + )} {uploaded && }
); diff --git a/server/media/imgproxy-media.js b/server/media/imgproxy-media.js index 85bc3393f..b6f480e5b 100644 --- a/server/media/imgproxy-media.js +++ b/server/media/imgproxy-media.js @@ -44,25 +44,25 @@ const generateSignedUploadUrls = async (req, res) => { for (const filename of filenames) { const key = filename; const client = new S3Client({ region: InstanceRegion() }); - + // Check if filename indicates PDF and set content type accordingly - const isPdf = filename.toLowerCase().endsWith('.pdf'); + const isPdf = filename.toLowerCase().endsWith(".pdf"); const commandParams = { Bucket: imgproxyDestinationBucket, Key: key, StorageClass: "INTELLIGENT_TIERING" }; - + if (isPdf) { commandParams.ContentType = "application/pdf"; } - + const command = new PutObjectCommand(commandParams); // For PDFs, we need to add conditions to the presigned URL to enforce content type const presignedUrlOptions = { expiresIn: 360 }; if (isPdf) { - presignedUrlOptions.signableHeaders = new Set(['content-type']); + presignedUrlOptions.signableHeaders = new Set(["content-type"]); } const presignedUrl = await getSignedUrl(client, command, presignedUrlOptions); @@ -265,6 +265,82 @@ const downloadFiles = async (req, res) => { } }; +/** + * Stream original image content by document ID + * @param req + * @param res + * @returns {Promise<*>} + */ +const getOriginalImageByDocumentId = async (req, res) => { + const { + body: { documentId }, + user, + userGraphQLClient + } = req; + + if (!documentId) { + return res.status(400).json({ message: "documentId is required" }); + } + + try { + logger.log("imgproxy-original-image", "DEBUG", user?.email, null, { documentId }); + + const { documents } = await userGraphQLClient.request(GET_DOCUMENTS_BY_IDS, { documentIds: [documentId] }); + + if (!documents || documents.length === 0) { + return res.status(404).json({ message: "Document not found" }); + } + + const [document] = documents; + const { type } = document; + + if (!type || !type.startsWith("image")) { + return res.status(400).json({ message: "Document is not an image" }); + } + + const s3client = new S3Client({ region: InstanceRegion() }); + const key = keyStandardize(document); + + let s3Response; + try { + s3Response = await s3client.send( + new GetObjectCommand({ + Bucket: imgproxyDestinationBucket, + Key: key + }) + ); + } catch (err) { + logger.log("imgproxy-original-image-s3-error", "ERROR", user?.email, null, { + key, + message: err.message, + stack: err.stack + }); + return res.status(400).json({ message: "Unable to retrieve image" }); + } + + res.setHeader("Content-Type", type || "image/jpeg"); + + s3Response.Body.on("error", (err) => { + logger.log("imgproxy-original-image-s3stream-error", "ERROR", user?.email, null, { + key, + message: err.message, + stack: err.stack + }); + res.destroy(err); + }); + + s3Response.Body.pipe(res); + } catch (error) { + logger.log("imgproxy-original-image-error", "ERROR", req.user?.email, null, { + documentId, + message: error.message, + stack: error.stack + }); + + return res.status(400).json({ message: error.message, stack: error.stack }); + } +}; + /** * Delete Files * @param req @@ -425,6 +501,7 @@ const keyStandardize = (doc) => { module.exports = { generateSignedUploadUrls, getThumbnailUrls, + getOriginalImageByDocumentId, downloadFiles, deleteFiles, moveFiles diff --git a/server/routes/mediaRoutes.js b/server/routes/mediaRoutes.js index 59ee836ec..1f356c5b9 100644 --- a/server/routes/mediaRoutes.js +++ b/server/routes/mediaRoutes.js @@ -4,6 +4,7 @@ const { createSignedUploadURL, downloadFiles, renameKeys, deleteFiles } = requir const { generateSignedUploadUrls: createSignedUploadURLImgproxy, getThumbnailUrls: getThumbnailUrlsImgproxy, + getOriginalImageByDocumentId: getOriginalImageByDocumentIdImgproxy, downloadFiles: downloadFilesImgproxy, moveFiles: moveFilesImgproxy, deleteFiles: deleteFilesImgproxy @@ -24,5 +25,6 @@ router.post("/imgproxy/thumbnails", getThumbnailUrlsImgproxy); router.post("/imgproxy/download", downloadFilesImgproxy); router.post("/imgproxy/rename", moveFilesImgproxy); router.post("/imgproxy/delete", deleteFilesImgproxy); +router.post("/imgproxy/original", getOriginalImageByDocumentIdImgproxy); module.exports = router; From 6eb432b5b714786bd3c2f8663b168417d36e922d Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Fri, 12 Dec 2025 20:56:39 -0800 Subject: [PATCH 02/32] IO-3464 Local Media Edit Image Signed-off-by: Allan Carr --- client/src/App/App.jsx | 4 + .../document-editor-local.component.jsx | 162 ++++++++++++++++++ .../document-editor-local.container.jsx | 34 ++++ ...jobs-documents-local-gallery.container.jsx | 17 +- 4 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 client/src/components/document-editor/document-editor-local.component.jsx create mode 100644 client/src/components/document-editor/document-editor-local.container.jsx diff --git a/client/src/App/App.jsx b/client/src/App/App.jsx index e14162274..69da6f077 100644 --- a/client/src/App/App.jsx +++ b/client/src/App/App.jsx @@ -7,6 +7,7 @@ import { connect } from "react-redux"; import { Route, Routes, useNavigate } from "react-router-dom"; import { createStructuredSelector } from "reselect"; import DocumentEditorContainer from "../components/document-editor/document-editor.container"; +import DocumentEditorLocalContainer from "../components/document-editor/document-editor-local.container"; import ErrorBoundary from "../components/error-boundary/error-boundary.component"; import LoadingSpinner from "../components/loading-spinner/loading-spinner.component"; import DisclaimerPage from "../pages/disclaimer/disclaimer.page"; @@ -241,6 +242,9 @@ export function App({ }> } /> + }> + } /> + diff --git a/client/src/components/document-editor/document-editor-local.component.jsx b/client/src/components/document-editor/document-editor-local.component.jsx new file mode 100644 index 000000000..b1511352a --- /dev/null +++ b/client/src/components/document-editor/document-editor-local.component.jsx @@ -0,0 +1,162 @@ +import axios from "axios"; +import { Result } from "antd"; +import * as markerjs2 from "markerjs2"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; +import { handleUpload } from "../documents-local-upload/documents-local-upload.utility"; +import LoadingSpinner from "../loading-spinner/loading-spinner.component"; +import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; + +const mapStateToProps = createStructuredSelector({ + currentUser: selectCurrentUser, + bodyshop: selectBodyshop +}); +const mapDispatchToProps = () => ({}); + +export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { + const imgRef = useRef(null); + const [loading, setLoading] = useState(false); + const [uploaded, setuploaded] = useState(false); + const [loadedImageUrl, setLoadedImageUrl] = useState(null); + const [imageLoaded, setImageLoaded] = useState(false); + const [imageLoading, setImageLoading] = useState(true); + const markerArea = useRef(null); + const { t } = useTranslation(); + const notification = useNotification(); + const [uploading, setUploading] = useState(false); + + const triggerUpload = useCallback( + async (dataUrl) => { + if (uploading) return; + setUploading(true); + const blob = await b64toBlob(dataUrl); + const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim(); + const parts = nameWithoutExt.split("-"); + const baseParts = []; + for (let i = 0; i < parts.length; i++) { + if (/^\d+$/.test(parts[i])) { + break; + } + baseParts.push(parts[i]); + } + const adjustedBase = baseParts.length > 0 ? baseParts.join("-") : "edited"; + const adjustedFilename = `${adjustedBase}.jpg`; + const file = new File([blob], adjustedFilename, { type: "image/jpeg" }); + + handleUpload({ + ev: { + file: file, + filename: adjustedFilename, + onSuccess: () => { + setUploading(false); + setLoading(false); + setuploaded(true); + }, + onError: () => { + setUploading(false); + setLoading(false); + } + }, + context: { + jobid: jobid, + callback: () => {} // Optional callback + }, + notification + }); + }, + [filename, jobid, notification, uploading] + ); + + useEffect(() => { + if (imgRef.current !== null && imageLoaded && !markerArea.current) { + // create a marker.js MarkerArea + markerArea.current = new markerjs2.MarkerArea(imgRef.current); + + // attach an event handler to assign annotated image back to our image element + markerArea.current.addEventListener("close", () => { + // NO OP + }); + + markerArea.current.addEventListener("render", (event) => { + const dataUrl = event.dataUrl; + imgRef.current.src = dataUrl; + markerArea.current.close(); + triggerUpload(dataUrl); + }); + // launch marker.js + + markerArea.current.renderAtNaturalSize = true; + markerArea.current.renderImageType = "image/jpeg"; + markerArea.current.renderImageQuality = 1; + //markerArea.current.settings.displayMode = "inline"; + markerArea.current.show(); + } + }, [triggerUpload, imageLoaded]); + + useEffect(() => { + // Load the image from imageUrl + let isCancelled = false; + + const loadImage = async () => { + if (!imageUrl) return; + + setImageLoaded(false); + setImageLoading(true); + + try { + const response = await axios.get(imageUrl, { responseType: "blob" }); + + if (isCancelled) return; + + const blobUrl = URL.createObjectURL(response.data); + setLoadedImageUrl((prevUrl) => { + if (prevUrl) URL.revokeObjectURL(prevUrl); + return blobUrl; + }); + } catch (error) { + console.error("Failed to fetch image blob", error); + } finally { + if (!isCancelled) { + setImageLoading(false); + } + } + }; + + loadImage(); + + return () => { + isCancelled = true; + }; + }, [imageUrl]); + + async function b64toBlob(url) { + const res = await fetch(url); + return await res.blob(); + } + + return ( +
+ {!loading && !uploaded && loadedImageUrl && ( + sample setImageLoaded(true)} + onError={(error) => { + console.error("Failed to load image", error); + }} + style={{ maxWidth: "90vw", maxHeight: "90vh" }} + /> + )} + {(loading || imageLoading || !imageLoaded) && !uploaded && ( + + )} + {uploaded && } +
+ ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(DocumentEditorLocalComponent); diff --git a/client/src/components/document-editor/document-editor-local.container.jsx b/client/src/components/document-editor/document-editor-local.container.jsx new file mode 100644 index 000000000..ce8aab23d --- /dev/null +++ b/client/src/components/document-editor/document-editor-local.container.jsx @@ -0,0 +1,34 @@ +import { useQuery } from "@apollo/client"; +import queryString from "query-string"; +import { useEffect } from "react"; +import { connect } from "react-redux"; +import { useLocation } from "react-router-dom"; +import { QUERY_BODYSHOP } from "../../graphql/bodyshop.queries"; +import { setBodyshop } from "../../redux/user/user.actions"; +import DocumentEditorLocalComponent from "./document-editor-local.component"; + +const mapDispatchToProps = (dispatch) => ({ + setBodyshop: (bs) => dispatch(setBodyshop(bs)) +}); + +export default connect(null, mapDispatchToProps)(DocumentEditorLocalContainer); + +export function DocumentEditorLocalContainer({ setBodyshop }) { + // Get the imageUrl, filename, jobid from the search string. + const { imageUrl, filename, jobid } = queryString.parse(useLocation().search); + + const { data: dataShop } = useQuery(QUERY_BODYSHOP, { + fetchPolicy: "network-only", + nextFetchPolicy: "network-only" + }); + + useEffect(() => { + if (dataShop) setBodyshop(dataShop.bodyshops[0]); + }, [dataShop, setBodyshop]); + + return ( +
+ +
+ ); +} diff --git a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx index f5570257f..da9274eef 100644 --- a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx +++ b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx @@ -1,4 +1,4 @@ -import { FileExcelFilled, SyncOutlined } from "@ant-design/icons"; +import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons"; import { Alert, Button, Card, Col, Row, Space } from "antd"; import { useEffect, useState } from "react"; import { Gallery } from "react-grid-gallery"; @@ -185,6 +185,21 @@ export function JobsDocumentsLocalGallery({ {modalState.open && ( { + const newWindow = window.open( + `${window.location.protocol}//${window.location.host}/edit-local?imageUrl=${ + jobMedia.images[modalState.index].fullsize + }&filename=${jobMedia.images[modalState.index].filename}&jobid=${job.id}`, + "_blank", + "noopener,noreferrer" + ); + if (newWindow) newWindow.opener = null; + }} + /> + ]} mainSrc={jobMedia.images[modalState.index].fullsize} nextSrc={jobMedia.images[(modalState.index + 1) % jobMedia.images.length].fullsize} prevSrc={jobMedia.images[(modalState.index + jobMedia.images.length - 1) % jobMedia.images.length].fullsize} From c675a328a8f3dea2b5f2c436f22ebd9278c09c0e Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Mon, 15 Dec 2025 12:40:43 -0800 Subject: [PATCH 03/32] IO-3464 Remove extra edit route Signed-off-by: Allan Carr --- client/src/App/App.jsx | 4 --- .../document-editor-local.container.jsx | 34 ------------------- .../document-editor.container.jsx | 22 ++++++++++-- 3 files changed, 19 insertions(+), 41 deletions(-) delete mode 100644 client/src/components/document-editor/document-editor-local.container.jsx diff --git a/client/src/App/App.jsx b/client/src/App/App.jsx index 69da6f077..e14162274 100644 --- a/client/src/App/App.jsx +++ b/client/src/App/App.jsx @@ -7,7 +7,6 @@ import { connect } from "react-redux"; import { Route, Routes, useNavigate } from "react-router-dom"; import { createStructuredSelector } from "reselect"; import DocumentEditorContainer from "../components/document-editor/document-editor.container"; -import DocumentEditorLocalContainer from "../components/document-editor/document-editor-local.container"; import ErrorBoundary from "../components/error-boundary/error-boundary.component"; import LoadingSpinner from "../components/loading-spinner/loading-spinner.component"; import DisclaimerPage from "../pages/disclaimer/disclaimer.page"; @@ -242,9 +241,6 @@ export function App({ }> } /> - }> - } /> - diff --git a/client/src/components/document-editor/document-editor-local.container.jsx b/client/src/components/document-editor/document-editor-local.container.jsx deleted file mode 100644 index ce8aab23d..000000000 --- a/client/src/components/document-editor/document-editor-local.container.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useQuery } from "@apollo/client"; -import queryString from "query-string"; -import { useEffect } from "react"; -import { connect } from "react-redux"; -import { useLocation } from "react-router-dom"; -import { QUERY_BODYSHOP } from "../../graphql/bodyshop.queries"; -import { setBodyshop } from "../../redux/user/user.actions"; -import DocumentEditorLocalComponent from "./document-editor-local.component"; - -const mapDispatchToProps = (dispatch) => ({ - setBodyshop: (bs) => dispatch(setBodyshop(bs)) -}); - -export default connect(null, mapDispatchToProps)(DocumentEditorLocalContainer); - -export function DocumentEditorLocalContainer({ setBodyshop }) { - // Get the imageUrl, filename, jobid from the search string. - const { imageUrl, filename, jobid } = queryString.parse(useLocation().search); - - const { data: dataShop } = useQuery(QUERY_BODYSHOP, { - fetchPolicy: "network-only", - nextFetchPolicy: "network-only" - }); - - useEffect(() => { - if (dataShop) setBodyshop(dataShop.bodyshops[0]); - }, [dataShop, setBodyshop]); - - return ( -
- -
- ); -} diff --git a/client/src/components/document-editor/document-editor.container.jsx b/client/src/components/document-editor/document-editor.container.jsx index d19fb9ce3..e10483694 100644 --- a/client/src/components/document-editor/document-editor.container.jsx +++ b/client/src/components/document-editor/document-editor.container.jsx @@ -11,6 +11,7 @@ import { setBodyshop } from "../../redux/user/user.actions"; import AlertComponent from "../alert/alert.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import DocumentEditor from "./document-editor.component"; +import { DocumentEditorLocalComponent } from "./document-editor-local.component"; const mapDispatchToProps = (dispatch) => ({ setBodyshop: (bs) => dispatch(setBodyshop(bs)) @@ -21,7 +22,7 @@ export default connect(null, mapDispatchToProps)(DocumentEditorContainer); export function DocumentEditorContainer({ setBodyshop }) { //Get the image details for the image to be saved. //Get the document id from the search string. - const { documentId } = queryString.parse(useLocation().search); + const { documentId, imageUrl, filename, jobid } = queryString.parse(useLocation().search); const { t } = useTranslation(); const { loading: loadingShop, @@ -36,6 +37,21 @@ export function DocumentEditorContainer({ setBodyshop }) { if (dataShop) setBodyshop(dataShop.bodyshops[0]); }, [dataShop, setBodyshop]); + if (loadingShop) return ; + if (errorShop) return ; + + if (dataShop?.bodyshops[0]?.uselocalmediaserver) { + if (imageUrl && filename && jobid) { + return ( +
+ +
+ ); + } else { + return ; + } + } + const { loading, error, data } = useQuery(GET_DOCUMENT_BY_PK, { variables: { documentId }, skip: !documentId, @@ -43,8 +59,8 @@ export function DocumentEditorContainer({ setBodyshop }) { nextFetchPolicy: "network-only" }); - if (loading || loadingShop) return ; - if (error || errorShop) return ; + if (loading) return ; + if (error) return ; if (!data || !data.documents_by_pk) return ; return ( From dfe0afd4f3528dbf895d1ed81734a0b2d718b7b1 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Thu, 18 Dec 2025 11:22:28 -0800 Subject: [PATCH 04/32] IO-3464 Document Edit Signed-off-by: Allan Carr --- .../document-editor-local.component.jsx | 30 +++++++++------- .../document-editor.component.jsx | 35 +++++++++++-------- .../document-editor.container.jsx | 30 +++++++++------- ...jobs-documents-local-gallery.container.jsx | 2 +- 4 files changed, 56 insertions(+), 41 deletions(-) diff --git a/client/src/components/document-editor/document-editor-local.component.jsx b/client/src/components/document-editor/document-editor-local.component.jsx index b1511352a..18bf0ead8 100644 --- a/client/src/components/document-editor/document-editor-local.component.jsx +++ b/client/src/components/document-editor/document-editor-local.component.jsx @@ -97,41 +97,45 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { }, [triggerUpload, imageLoaded]); useEffect(() => { - // Load the image from imageUrl - let isCancelled = false; + if (!imageUrl) return; + const controller = new AbortController(); const loadImage = async () => { - if (!imageUrl) return; - setImageLoaded(false); setImageLoading(true); - try { - const response = await axios.get(imageUrl, { responseType: "blob" }); - - if (isCancelled) return; - + const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal }); const blobUrl = URL.createObjectURL(response.data); setLoadedImageUrl((prevUrl) => { if (prevUrl) URL.revokeObjectURL(prevUrl); return blobUrl; }); } catch (error) { + if (axios.isCancel?.(error) || error.name === "CanceledError") { + // request was aborted — safe to ignore + return; + } console.error("Failed to fetch image blob", error); } finally { - if (!isCancelled) { + if (!controller.signal.aborted) { setImageLoading(false); } } }; - loadImage(); - return () => { - isCancelled = true; + controller.abort(); }; }, [imageUrl]); + useEffect(() => { + return () => { + if (loadedImageUrl) { + URL.revokeObjectURL(loadedImageUrl); + } + }; + }, [loadedImageUrl]); + async function b64toBlob(url) { const res = await fetch(url); return await res.blob(); diff --git a/client/src/components/document-editor/document-editor.component.jsx b/client/src/components/document-editor/document-editor.component.jsx index 8df1be57f..1bdf0f9e7 100644 --- a/client/src/components/document-editor/document-editor.component.jsx +++ b/client/src/components/document-editor/document-editor.component.jsx @@ -84,45 +84,50 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { }, [triggerUpload, imageLoaded]); useEffect(() => { - // When the document changes, fetch the image via axios so auth and base URL are applied - let isCancelled = false; + if (!document?.id) return; + const controller = new AbortController(); const loadImage = async () => { - if (!document || !document.id) return; - setImageLoaded(false); setImageLoading(true); - try { const response = await axios.post( "/media/imgproxy/original", { documentId: document.id }, - { responseType: "blob" } + { + responseType: "blob", + signal: controller.signal + } ); - - if (isCancelled) return; - const blobUrl = URL.createObjectURL(response.data); setImageUrl((prevUrl) => { if (prevUrl) URL.revokeObjectURL(prevUrl); return blobUrl; }); } catch (error) { + if (axios.isCancel?.(error) || error.name === "CanceledError") { + // request was aborted — safe to ignore + return; + } console.error("Failed to fetch original image blob", error); } finally { - if (!isCancelled) { - setImageLoading(false); - } + setImageLoading(false); } }; - loadImage(); - return () => { - isCancelled = true; + controller.abort(); }; }, [document]); + useEffect(() => { + return () => { + if (imageUrl) { + URL.revokeObjectURL(imageUrl); + } + }; + }, [imageUrl]); + async function b64toBlob(url) { const res = await fetch(url); return await res.blob(); diff --git a/client/src/components/document-editor/document-editor.container.jsx b/client/src/components/document-editor/document-editor.container.jsx index e10483694..ed2736d09 100644 --- a/client/src/components/document-editor/document-editor.container.jsx +++ b/client/src/components/document-editor/document-editor.container.jsx @@ -33,6 +33,19 @@ export function DocumentEditorContainer({ setBodyshop }) { nextFetchPolicy: "network-only" }); + const isLocalMedia = !!dataShop?.bodyshops?.[0]?.uselocalmediaserver; + + const { + loading: loadingDoc, + error: errorDoc, + data: dataDoc + } = useQuery(GET_DOCUMENT_BY_PK, { + variables: { documentId }, + skip: !documentId || isLocalMedia, + fetchPolicy: "network-only", + nextFetchPolicy: "network-only" + }); + useEffect(() => { if (dataShop) setBodyshop(dataShop.bodyshops[0]); }, [dataShop, setBodyshop]); @@ -40,7 +53,7 @@ export function DocumentEditorContainer({ setBodyshop }) { if (loadingShop) return ; if (errorShop) return ; - if (dataShop?.bodyshops[0]?.uselocalmediaserver) { + if (isLocalMedia) { if (imageUrl && filename && jobid) { return (
@@ -52,20 +65,13 @@ export function DocumentEditorContainer({ setBodyshop }) { } } - const { loading, error, data } = useQuery(GET_DOCUMENT_BY_PK, { - variables: { documentId }, - skip: !documentId, - fetchPolicy: "network-only", - nextFetchPolicy: "network-only" - }); + if (loadingDoc) return ; + if (errorDoc) return ; - if (loading) return ; - if (error) return ; - - if (!data || !data.documents_by_pk) return ; + if (!dataDoc || !dataDoc.documents_by_pk) return ; return (
- +
); } diff --git a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx index da9274eef..da94678a8 100644 --- a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx +++ b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx @@ -190,7 +190,7 @@ export function JobsDocumentsLocalGallery({ key="edit" onClick={() => { const newWindow = window.open( - `${window.location.protocol}//${window.location.host}/edit-local?imageUrl=${ + `${window.location.protocol}//${window.location.host}/edit?imageUrl=${ jobMedia.images[modalState.index].fullsize }&filename=${jobMedia.images[modalState.index].filename}&jobid=${job.id}`, "_blank", From 9b44dd844f5cc04baa9fe8c0160bca4bdce087c9 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 22 Dec 2025 14:18:13 -0500 Subject: [PATCH 05/32] feature/IO-3487-Auto-Add-Profile-Watchers - Fix Auto Add on a profile level --- .../contract-convert-to-ro.component.jsx | 4 +++ .../job-create-iou.component.jsx | 14 ++++---- .../jobs-available-table.container.jsx | 4 +++ .../jobs-detail-header-actions.component.jsx | 32 ++++++++++++------- ...bs-detail-header-actions.duplicate.util.js | 19 +++++++++-- .../jobs-create/jobs-create.container.jsx | 15 ++++++--- hasura/metadata/tables.yaml | 5 ++- .../down.sql | 4 +++ .../up.sql | 2 ++ server/graphql-client/queries.js | 6 ++-- server/notifications/autoAddWatchers.js | 4 ++- 11 files changed, 81 insertions(+), 28 deletions(-) create mode 100644 hasura/migrations/1766427606596_alter_table_public_jobs_add_column_created_user_email/down.sql create mode 100644 hasura/migrations/1766427606596_alter_table_public_jobs_add_column_created_user_email/up.sql diff --git a/client/src/components/contract-convert-to-ro/contract-convert-to-ro.component.jsx b/client/src/components/contract-convert-to-ro/contract-convert-to-ro.component.jsx index 36d7b977a..d53c2c151 100644 --- a/client/src/components/contract-convert-to-ro/contract-convert-to-ro.component.jsx +++ b/client/src/components/contract-convert-to-ro/contract-convert-to-ro.component.jsx @@ -253,6 +253,10 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled } }; + if (currentUser?.email) { + newJob.created_user_email = currentUser.email; + } + //Calcualte the new job totals. const newTotals = ( diff --git a/client/src/components/job-create-iou/job-create-iou.component.jsx b/client/src/components/job-create-iou/job-create-iou.component.jsx index a09b41224..2aa027218 100644 --- a/client/src/components/job-create-iou/job-create-iou.component.jsx +++ b/client/src/components/job-create-iou/job-create-iou.component.jsx @@ -43,16 +43,18 @@ export function JobCreateIOU({ bodyshop, currentUser, job, selectedJobLines, tec const handleCreateIou = async () => { setLoading(true); //Query all of the job details to recreate. - const iouId = await CreateIouForJob( - client, - job.id, - { + const iouId = await CreateIouForJob({ + apolloClient: client, + jobLinesToKeep: selectedJobLines, + jobId: job.id, + config: { status: bodyshop.md_ro_statuses.default_open, bodyshopid: bodyshop.id, useremail: currentUser.email }, - selectedJobLines - ); + currentUser + }); + notification.open({ type: "success", message: t("jobs.successes.ioucreated"), diff --git a/client/src/components/jobs-available-table/jobs-available-table.container.jsx b/client/src/components/jobs-available-table/jobs-available-table.container.jsx index f5e6589bd..35a53aefd 100644 --- a/client/src/components/jobs-available-table/jobs-available-table.container.jsx +++ b/client/src/components/jobs-available-table/jobs-available-table.container.jsx @@ -154,6 +154,10 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail : {}) }; + if (currentUser?.email) { + newJob.created_user_email = currentUser.email; + } + if (selectedOwner) { newJob.ownerid = selectedOwner; delete newJob.owner; diff --git a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx index 655694d8f..42aa416fa 100644 --- a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx +++ b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx @@ -175,25 +175,33 @@ export function JobsDetailHeaderActions({ }; const handleDuplicate = () => - DuplicateJob( - client, - job.id, - { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported }, - (newJobId) => { + DuplicateJob({ + apolloClient: client, + jobId: job.id, + config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported }, + completionCallback: (newJobId) => { history(`/manage/jobs/${newJobId}`); notification.success({ message: t("jobs.successes.duplicated") }); }, - true - ); + keepJobLines: true, + currentUser + }); const handleDuplicateConfirm = () => - DuplicateJob(client, job.id, { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported }, (newJobId) => { - history(`/manage/jobs/${newJobId}`); - notification.success({ - message: t("jobs.successes.duplicated") - }); + DuplicateJob({ + apolloClient: client, + jobId: job.id, + config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported }, + completionCallback: (newJobId) => { + history(`/manage/jobs/${newJobId}`); + notification.success({ + message: t("jobs.successes.duplicated") + }); + }, + keepJobLines: false, + currentUser }); const handleFinish = async (values) => { diff --git a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.duplicate.util.js b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.duplicate.util.js index 7706f91bc..8e0705e79 100644 --- a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.duplicate.util.js +++ b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.duplicate.util.js @@ -5,7 +5,14 @@ import { INSERT_NEW_JOB, QUERY_JOB_FOR_DUPE } from "../../graphql/jobs.queries"; import dayjs from "../../utils/day"; import i18n from "i18next"; -export default async function DuplicateJob(apolloClient, jobId, config, completionCallback, keepJobLines = false) { +export default async function DuplicateJob({ + apolloClient, + jobId, + config, + completionCallback, + keepJobLines = false, + currentUser +}) { logImEXEvent("job_duplicate"); const { defaultOpenStatus } = config; @@ -19,6 +26,7 @@ export default async function DuplicateJob(apolloClient, jobId, config, completi const existingJob = _.cloneDeep(jobs_by_pk); delete existingJob.__typename; delete existingJob.id; + delete existingJob.created_user_email; delete existingJob.createdat; delete existingJob.updatedat; delete existingJob.cieca_stl; @@ -29,6 +37,10 @@ export default async function DuplicateJob(apolloClient, jobId, config, completi status: defaultOpenStatus }; + if (currentUser?.email) { + newJob.created_user_email = currentUser.email; + } + const _tempLines = _.cloneDeep(existingJob.joblines); _tempLines.forEach((line) => { delete line.id; @@ -55,7 +67,7 @@ export default async function DuplicateJob(apolloClient, jobId, config, completi return; } -export async function CreateIouForJob(apolloClient, jobId, config, jobLinesToKeep) { +export async function CreateIouForJob({ apolloClient, jobId, config, jobLinesToKeep, currentUser }) { logImEXEvent("job_create_iou"); const { status } = config; @@ -109,6 +121,9 @@ export async function CreateIouForJob(apolloClient, jobId, config, jobLinesToKee delete newJob.joblines; newJob.joblines = { data: _tempLines }; + if (currentUser?.email) { + newJob.created_user_email = currentUser.email; + } const res2 = await apolloClient.mutate({ mutation: INSERT_NEW_JOB, variables: { job: [newJob] } diff --git a/client/src/pages/jobs-create/jobs-create.container.jsx b/client/src/pages/jobs-create/jobs-create.container.jsx index 85f2b3ffe..9905a72fc 100644 --- a/client/src/pages/jobs-create/jobs-create.container.jsx +++ b/client/src/pages/jobs-create/jobs-create.container.jsx @@ -9,21 +9,23 @@ import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component"; import { INSERT_NEW_JOB } from "../../graphql/jobs.queries"; import { QUERY_OWNER_FOR_JOB_CREATION } from "../../graphql/owners.queries"; import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; -import { selectBodyshop } from "../../redux/user/user.selectors"; +import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import InstanceRenderManager from "../../utils/instanceRenderMgr"; import JobsCreateComponent from "./jobs-create.component"; import JobCreateContext from "./jobs-create.context"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { logImEXEvent } from "../../firebase/firebase.utils"; + const mapStateToProps = createStructuredSelector({ - bodyshop: selectBodyshop + bodyshop: selectBodyshop, + currentUser: selectCurrentUser }); const mapDispatchToProps = (dispatch) => ({ setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)), setSelectedHeader: (key) => dispatch(setSelectedHeader(key)) }); -function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) { +function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser }) { const { t } = useTranslation(); const notification = useNotification(); @@ -74,7 +76,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) { }, [t, setBreadcrumbs, setSelectedHeader]); const runInsertJob = (job) => { - insertJob({ variables: { job: job } }) + insertJob({ variables: { job } }) .then((resp) => { setState({ ...state, @@ -150,6 +152,11 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) { if (job.owner === null) delete job.owner; if (job.vehicle === null) delete job.vehicle; + // Associate to the current user if one exists + if (currentUser?.email) { + job.created_user_email = currentUser.email; + } + runInsertJob(job); }; diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 9900fa00b..8231c2dd0 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -3684,6 +3684,7 @@ - completed_tasks - converted - created_at + - created_user_email - cust_pr - date_estimated - date_exported @@ -3961,6 +3962,7 @@ - completed_tasks - converted - created_at + - created_user_email - cust_pr - date_estimated - date_exported @@ -4251,6 +4253,7 @@ - completed_tasks - converted - created_at + - created_user_email - cust_pr - date_estimated - date_exported @@ -4641,7 +4644,7 @@ request_transform: body: action: transform - template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": {{$body.event.op}},\r\n \"data\": {\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"shopid\": {{$body.event.data.new?.shopid}},\r\n \"ro_number\": {{$body.event.data.new?.ro_number}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs_autoadd\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n" + template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": {{$body.event.op}},\r\n \"data\": {\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"shopid\": {{$body.event.data.new?.shopid}},\r\n \"ro_number\": {{$body.event.data.new?.ro_number}},\r\n \"created_user_email\": {{$body.event.data.new?.created_user_email}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs_autoadd\"\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 diff --git a/hasura/migrations/1766427606596_alter_table_public_jobs_add_column_created_user_email/down.sql b/hasura/migrations/1766427606596_alter_table_public_jobs_add_column_created_user_email/down.sql new file mode 100644 index 000000000..ddb7199ff --- /dev/null +++ b/hasura/migrations/1766427606596_alter_table_public_jobs_add_column_created_user_email/down.sql @@ -0,0 +1,4 @@ +-- Could not auto-generate a down migration. +-- Please write an appropriate down migration for the SQL below: +-- alter table "public"."jobs" add column "created_user_email" text +-- null; diff --git a/hasura/migrations/1766427606596_alter_table_public_jobs_add_column_created_user_email/up.sql b/hasura/migrations/1766427606596_alter_table_public_jobs_add_column_created_user_email/up.sql new file mode 100644 index 000000000..d8244715b --- /dev/null +++ b/hasura/migrations/1766427606596_alter_table_public_jobs_add_column_created_user_email/up.sql @@ -0,0 +1,2 @@ +alter table "public"."jobs" add column "created_user_email" text + null; diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index b51c73c49..a4ed143dc 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -3089,17 +3089,19 @@ exports.INSERT_JOB_WATCHERS = ` `; exports.GET_NOTIFICATION_WATCHERS = ` - query GET_NOTIFICATION_WATCHERS($shopId: uuid!, $employeeIds: [uuid!]!) { + query GET_NOTIFICATION_WATCHERS($shopId: uuid!, $employeeIds: [uuid!]!, $createdUserEmail: String!) { associations(where: { _and: [ { shopid: { _eq: $shopId } }, { active: { _eq: true } }, - { notifications_autoadd: { _eq: true } } + { notifications_autoadd: { _eq: true } }, + { useremail: { _eq: $createdUserEmail } } ] }) { id useremail } + employees(where: { id: { _in: $employeeIds }, shopid: { _eq: $shopId }, active: { _eq: true } }) { user_email } diff --git a/server/notifications/autoAddWatchers.js b/server/notifications/autoAddWatchers.js index 803c44984..0a0fa3e04 100644 --- a/server/notifications/autoAddWatchers.js +++ b/server/notifications/autoAddWatchers.js @@ -39,6 +39,7 @@ const autoAddWatchers = async (req) => { const jobId = event?.data?.new?.id; const shopId = event?.data?.new?.shopid; const roNumber = event?.data?.new?.ro_number || "unknown"; + const createdUserEmail = event?.data?.new?.created_user_email || "Unknown"; if (!jobId || !shopId) { throw new Error(`Missing jobId (${jobId}) or shopId (${shopId}) for auto-add watchers`); @@ -61,7 +62,8 @@ const autoAddWatchers = async (req) => { const [notificationData, existingWatchersData] = await Promise.all([ gqlClient.request(GET_NOTIFICATION_WATCHERS, { shopId, - employeeIds: notificationFollowers + employeeIds: notificationFollowers, + createdUserEmail }), gqlClient.request(GET_JOB_WATCHERS_MINIMAL, { jobid: jobId }) ]); From 1ad7468d141f8bc3e64cee44629f63c2d8c2c2b3 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 23 Dec 2025 11:27:14 -0500 Subject: [PATCH 06/32] feature/IO-3401-Parts-Rec-Enhanced - Implement --- .../job-parts-received.component.jsx | 105 ++++++++++++++++++ .../parts-queue.list.component.jsx | 6 +- ...production-board-kanban-card.component.jsx | 16 +++ .../settings/InformationSettings.jsx | 3 +- .../settings/defaultKanbanSettings.js | 1 + ...n-list-columns.partsreceived.component.jsx | 34 +----- client/src/translations/en_us/common.json | 2 + client/src/translations/es/common.json | 2 + client/src/translations/fr/common.json | 2 + 9 files changed, 137 insertions(+), 34 deletions(-) create mode 100644 client/src/components/job-parts-received/job-parts-received.component.jsx diff --git a/client/src/components/job-parts-received/job-parts-received.component.jsx b/client/src/components/job-parts-received/job-parts-received.component.jsx new file mode 100644 index 000000000..64521fb5f --- /dev/null +++ b/client/src/components/job-parts-received/job-parts-received.component.jsx @@ -0,0 +1,105 @@ +import { useCallback, useMemo, useState } from "react"; +import PropTypes from "prop-types"; +import { Popover } from "antd"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { selectBodyshop } from "../../redux/user/user.selectors"; +import JobPartsQueueCount from "../job-parts-queue-count/job-parts-queue-count.component"; +import { useTranslation } from "react-i18next"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop +}); + +/** + * Displays "Parts Received" summary (modeled after the Production Board List column), + * and on click shows a popover with the Parts Status grid (existing JobPartsQueueCount UI). + * @param bodyshop + * @param parts + * @param displayMode + * @param popoverPlacement + * @returns {JSX.Element} + * @constructor + */ +export function JobPartsReceived({ bodyshop, parts, displayMode = "full", popoverPlacement = "top" }) { + const [open, setOpen] = useState(false); + const { t } = useTranslation(); + + const summary = useMemo(() => { + const receivedStatus = bodyshop?.md_order_statuses?.default_received; + + if (!Array.isArray(parts) || parts.length === 0 || !receivedStatus) { + return { total: 0, received: 0, percentLabel: t("general.labels.na") }; + } + + // Keep consistent with JobPartsQueueCount: exclude PAS / PASL from parts math + const { total, received } = parts.reduce( + (acc, val) => { + if (val?.part_type === "PAS" || val?.part_type === "PASL") return acc; + const count = Number(val?.count || 0); + acc.total += count; + + if (val?.status === receivedStatus) { + acc.received += count; + } + return acc; + }, + { total: 0, received: 0 } + ); + + const percentLabel = total > 0 ? `${Math.round((received / total) * 100)}%` : t("general.labels.na"); + return { total, received, percentLabel }; + }, [parts, bodyshop?.md_order_statuses?.default_received]); + + const canOpen = summary.total > 0; + + const handleOpenChange = useCallback( + (nextOpen) => { + if (!canOpen) return; + setOpen(nextOpen); + }, + [canOpen] + ); + + const displayText = + displayMode === "compact" ? summary.percentLabel : `${summary.percentLabel} (${summary.received}/${summary.total})`; + + // Prevent row/cell click handlers (table selection, drawer selection, etc.) + const stop = (e) => e.stopPropagation(); + + return ( + + +
+ } + > +
+ {displayText} +
+ + ); +} + +JobPartsReceived.propTypes = { + bodyshop: PropTypes.object, + parts: PropTypes.array, + displayMode: PropTypes.oneOf(["full", "compact"]), + popoverPlacement: PropTypes.string +}; + +export default connect(mapStateToProps)(JobPartsReceived); diff --git a/client/src/components/parts-queue-list/parts-queue.list.component.jsx b/client/src/components/parts-queue-list/parts-queue.list.component.jsx index a947eaefa..ff47d00fb 100644 --- a/client/src/components/parts-queue-list/parts-queue.list.component.jsx +++ b/client/src/components/parts-queue-list/parts-queue.list.component.jsx @@ -16,11 +16,11 @@ import { pageLimit } from "../../utils/config"; import { alphaSort, dateSort } from "../../utils/sorters"; import useLocalStorage from "../../utils/useLocalStorage"; import AlertComponent from "../alert/alert.component"; -import JobPartsQueueCount from "../job-parts-queue-count/job-parts-queue-count.component"; import JobRemoveFromPartsQueue from "../job-remove-from-parst-queue/job-remove-from-parts-queue.component"; import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component"; import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component"; import { logImEXEvent } from "../../firebase/firebase.utils"; +import JobPartsReceived from "../job-parts-received/job-parts-received.component"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -235,7 +235,9 @@ export function PartsQueueListComponent({ bodyshop }) { title: t("jobs.fields.partsstatus"), dataIndex: "partsstatus", key: "partsstatus", - render: (text, record) => + render: (text, record) => ( + + ) }, { title: t("jobs.fields.comment"), diff --git a/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx b/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx index 64cd69f9b..6f8d6d4a4 100644 --- a/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx +++ b/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx @@ -19,6 +19,7 @@ import JobPartsQueueCount from "../job-parts-queue-count/job-parts-queue-count.c import OwnerNameDisplay from "../owner-name-display/owner-name-display.component"; import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx"; import { PiMicrosoftTeamsLogo } from "react-icons/pi"; +import ProductionListColumnPartsReceived from "../production-list-columns/production-list-columns.partsreceived.component"; const cardColor = (ssbuckets, totalHrs) => { const bucket = ssbuckets.find((bucket) => bucket.gte <= totalHrs && (!bucket.lt || bucket.lt > totalHrs)); @@ -312,6 +313,20 @@ const TasksToolTip = ({ metadata, cardSettings, t }) => ); +const PartsReceivedComponent = ({ metadata, cardSettings, card }) => + cardSettings?.partsreceived && ( + + + + ); export default function ProductionBoardCard({ technician, card, bodyshop, cardSettings }) { const { t } = useTranslation(); const { metadata } = card; @@ -411,6 +426,7 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe + ); diff --git a/client/src/components/production-board-kanban/settings/InformationSettings.jsx b/client/src/components/production-board-kanban/settings/InformationSettings.jsx index ddf88c987..4ae4e781a 100644 --- a/client/src/components/production-board-kanban/settings/InformationSettings.jsx +++ b/client/src/components/production-board-kanban/settings/InformationSettings.jsx @@ -18,7 +18,8 @@ const InformationSettings = ({ t }) => ( "partsstatus", "estimator", "subtotal", - "tasks" + "tasks", + "partsreceived" ].map((item) => ( diff --git a/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js b/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js index 3c14a08f7..1c7a264b7 100644 --- a/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js +++ b/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js @@ -74,6 +74,7 @@ const defaultKanbanSettings = { cardSize: "small", model_info: true, kiosk: false, + partsreceived: false, totalHrs: true, totalAmountInProduction: false, totalLAB: true, diff --git a/client/src/components/production-list-columns/production-list-columns.partsreceived.component.jsx b/client/src/components/production-list-columns/production-list-columns.partsreceived.component.jsx index b1bf59f99..c4e3a0f6b 100644 --- a/client/src/components/production-list-columns/production-list-columns.partsreceived.component.jsx +++ b/client/src/components/production-list-columns/production-list-columns.partsreceived.component.jsx @@ -1,33 +1,5 @@ -import { useMemo } from "react"; -import { connect } from "react-redux"; -import { createStructuredSelector } from "reselect"; -import { selectBodyshop } from "../../redux/user/user.selectors"; +import JobPartsReceived from "../job-parts-received/job-parts-received.component"; -const mapStateToProps = createStructuredSelector({ - bodyshop: selectBodyshop -}); -const mapDispatchToProps = () => ({ - //setUserLanguage: language => dispatch(setUserLanguage(language)) -}); -export default connect(mapStateToProps, mapDispatchToProps)(ProductionListColumnPartsReceived); - -export function ProductionListColumnPartsReceived({ bodyshop, record }) { - const amount = useMemo(() => { - const amount = record.joblines_status.reduce( - (acc, val) => { - acc.total += val.count; - acc.received = - val.status === bodyshop.md_order_statuses.default_received ? acc.received + val.count : acc.received; - return acc; - }, - { total: 0, received: 0 } - ); - - return { - ...amount, - percent: amount.total !== 0 ? ((amount.received / amount.total) * 100).toFixed(0) + "%" : "N/A" - }; - }, [record, bodyshop.md_order_statuses]); - - return `${amount.percent} (${amount.received}/${amount.total})`; +export default function ProductionListColumnPartsReceived({ record }) { + return ; } diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index f79a1f166..7a8d5e8ce 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -2950,6 +2950,8 @@ "settings": "Error saving board settings: {{error}}" }, "labels": { + "click_for_statuses": "Click to view parts statuses", + "partsreceived": "Parts Received", "actual_in": "Actual In", "addnewprofile": "Add New Profile", "alert": "Alert", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 2a9a6229b..aa2299846 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -2950,6 +2950,8 @@ "settings": "" }, "labels": { + "click_for_statuses": "", + "partsreceived": "", "actual_in": "", "addnewprofile": "", "alert": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 939f7752e..91d2ab205 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -2950,6 +2950,8 @@ "settings": "" }, "labels": { + "click_for_statuses": "", + "partsreceived": "", "actual_in": "", "addnewprofile": "", "alert": "", From 9627800277c9fa6c13c09502aee08b52d2737124 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Fri, 26 Dec 2025 14:52:47 -0800 Subject: [PATCH 07/32] IO-3480 Production List Actual Completion Signed-off-by: Allan Carr --- .../production-list-columns.data.jsx | 17 ++++++++++++- client/src/graphql/jobs.queries.js | 2 ++ client/src/redux/user/user.sagas.js | 24 ++++++++----------- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/client/src/components/production-list-columns/production-list-columns.data.jsx b/client/src/components/production-list-columns/production-list-columns.data.jsx index 165e15c1d..91dd64c45 100644 --- a/client/src/components/production-list-columns/production-list-columns.data.jsx +++ b/client/src/components/production-list-columns/production-list-columns.data.jsx @@ -161,7 +161,6 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo dataIndex: "actual_in_time", key: "actual_in_time", ellipsis: true, - render: (text, record) => {record.actual_in} }, { @@ -181,6 +180,22 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo render: (text, record) => {record.scheduled_completion} }, + { + title: i18n.t("jobs.fields.actual_completion"), + dataIndex: "actual_completion", + key: "actual_completion", + ellipsis: true, + sorter: (a, b) => dateSort(a.actual_completion, b.actual_completion), + sortOrder: state.sortedInfo.columnKey === "actual_completion" && state.sortedInfo.order, + render: (text, record) => + }, + { + title: i18n.t("jobs.fields.actual_completion") + " (HH:MM)", + dataIndex: "actual_completion_time", + key: "actual_completion_time", + ellipsis: true, + render: (text, record) => {record.actual_completion} + }, { title: i18n.t("jobs.fields.date_last_contacted"), dataIndex: "date_last_contacted", diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js index 35dde7016..e1775728c 100644 --- a/client/src/graphql/jobs.queries.js +++ b/client/src/graphql/jobs.queries.js @@ -1096,6 +1096,7 @@ export const UPDATE_JOB = gql` scheduled_completion scheduled_delivery actual_in + actual_completion date_repairstarted date_void date_lost_sale @@ -2592,6 +2593,7 @@ export const QUERY_JOBS_IN_PRODUCTION = gql` vehicleid plate_no actual_in + actual_completion scheduled_completion scheduled_delivery date_last_contacted diff --git a/client/src/redux/user/user.sagas.js b/client/src/redux/user/user.sagas.js index 12533865c..37e19897f 100644 --- a/client/src/redux/user/user.sagas.js +++ b/client/src/redux/user/user.sagas.js @@ -239,22 +239,13 @@ export function* signInSuccessSaga({ payload }) { try { window.$crisp.push(["set", "user:nickname", [payload.displayName || payload.email]]); - const currentUserSegment = InstanceRenderManager({ - imex: "imex-online-user", - rome: "rome-online-user" - }); - window.$crisp.push(["set", "session:segments", [[currentUserSegment]]]); InstanceRenderManager({ executeFunction: true, args: [], - imex: () => { - window.$crisp.push(["set", "session:segments", [["imex"]]]); - }, rome: () => { window.$zoho.salesiq.visitor.name(payload.displayName || payload.email); window.$zoho.salesiq.visitor.email(payload.email); - window.$crisp.push(["set", "session:segments", [["rome"]]]); } }); @@ -262,11 +253,13 @@ export function* signInSuccessSaga({ payload }) { try { const state = yield select(); const isParts = state?.application?.isPartsEntry === true; - const instanceSeg = InstanceRenderManager({ imex: "imex", rome: "rome" }); + const instanceSeg = InstanceRenderManager({ + imex: ["imex-online-user", "imex"], + rome: ["rome-online-user", "rome"] + }); // Always ensure segments include instance + user, and append partsManagement if applicable const segs = [ - currentUserSegment, - instanceSeg, + ...instanceSeg, ...(isParts ? [ InstanceRenderManager({ @@ -373,7 +366,10 @@ export function* SetAuthLevelFromShopDetails({ payload }) { // Build consolidated Crisp segments including instance, region, features, and parts mode const isParts = yield select((state) => state.application.isPartsEntry === true); - const instanceSeg = InstanceRenderManager({ imex: "imex", rome: "rome" }); + const instanceSeg = InstanceRenderManager({ + imex: ["imex-online-user", "imex"], + rome: ["rome-online-user", "rome"] + }); const featureSegments = payload.features?.allAccess === true @@ -402,7 +398,7 @@ export function* SetAuthLevelFromShopDetails({ payload }) { featureSegments.push(...additionalSegments); const regionSeg = payload.region_config ? `region:${payload.region_config}` : null; - const segments = [instanceSeg, ...(regionSeg ? [regionSeg] : []), ...featureSegments]; + const segments = [...instanceSeg, ...(regionSeg ? [regionSeg] : []), ...featureSegments]; if (isParts) { segments.push(InstanceRenderManager({ imex: "ImexPartsManagement", rome: "RomePartsManagement" })); } From 155d0af5090858bbb2e5f77a4e09ab5b6e59a77f Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 30 Dec 2025 12:54:27 -0500 Subject: [PATCH 08/32] feature/IO-1710-prevent-duplicate-ins-companies --- .../shop-info/shop-info.general.component.jsx | 26 ++++++++++++++++++- client/src/translations/en_us/common.json | 3 ++- client/src/translations/es/common.json | 3 ++- client/src/translations/fr/common.json | 3 ++- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/client/src/components/shop-info/shop-info.general.component.jsx b/client/src/components/shop-info/shop-info.general.component.jsx index acf553bb5..955b8c217 100644 --- a/client/src/components/shop-info/shop-info.general.component.jsx +++ b/client/src/components/shop-info/shop-info.general.component.jsx @@ -934,6 +934,8 @@ export function ShopInfoGeneral({ form, bodyshop }) { }} + + {/*Start Insurance Provider Row */} {t("bodyshop.labels.insurancecos")}} @@ -950,11 +952,31 @@ export function ShopInfoGeneral({ form, bodyshop }) { label={t("bodyshop.fields.md_ins_co.name")} key={`${index}name`} name={[field.name, "name"]} + dependencies={[["md_ins_cos"]]} rules={[ { required: true //message: t("general.validation.required"), - } + }, + ({ getFieldValue }) => ({ + validator: async (_, value) => { + const normalizedValue = (value ?? "").toString().trim().toLowerCase(); + if (!normalizedValue) return Promise.resolve(); // handled by required + + const list = getFieldValue(["md_ins_cos"]) || []; + const normalizedNames = list + .map((c) => (c?.name ?? "").toString().trim().toLowerCase()) + .filter(Boolean); + + const count = normalizedNames.filter((n) => n === normalizedValue).length; + + if (count > 1) { + throw new Error(t("bodyshop.errors.duplicate_insurance_company")); + } + + return Promise.resolve(); + } + }) ]} > @@ -1031,6 +1053,8 @@ export function ShopInfoGeneral({ form, bodyshop }) { }} + {/*End Insurance Provider Row */} + {(fields, { add, remove, move }) => { diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index f79a1f166..efddd03a7 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -277,7 +277,8 @@ "errors": { "creatingdefaultview": "Error creating default view.", "loading": "Unable to load shop details. Please call technical support.", - "saving": "Error encountered while saving. {{message}}" + "saving": "Error encountered while saving. {{message}}", + "duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique" }, "fields": { "ReceivableCustomField": "QBO Receivable Custom Field {{number}}", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 2a9a6229b..8e3b313e4 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -277,7 +277,8 @@ "errors": { "creatingdefaultview": "", "loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.", - "saving": "" + "saving": "", + "duplicate_insurance_company": "" }, "fields": { "ReceivableCustomField": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 939f7752e..e60898129 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -277,7 +277,8 @@ "errors": { "creatingdefaultview": "", "loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.", - "saving": "" + "saving": "", + "duplicate_insurance_company": "" }, "fields": { "ReceivableCustomField": "", From 2c7b3285964e608aaae37b73629ef3f750d72399 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 30 Dec 2025 13:41:26 -0500 Subject: [PATCH 09/32] Initial --- .../registerMessagingSocketHandlers.js | 64 +++++++- .../chat-conversation-list.component.jsx | 65 ++++---- .../chat-conversation-title.component.jsx | 16 +- .../chat-conversation.component.jsx | 11 +- .../chat-conversation.container.jsx | 154 ++++++++++++++---- .../chat-mark-unread-button.component.jsx | 23 +++ client/src/graphql/conversations.queries.js | 30 +++- client/src/translations/en_us/common.json | 3 +- client/src/translations/es/common.json | 3 +- client/src/translations/fr/common.json | 3 +- 10 files changed, 292 insertions(+), 80 deletions(-) create mode 100644 client/src/components/chat-mark-unread-button/chat-mark-unread-button.component.jsx diff --git a/client/src/components/chat-affix/registerMessagingSocketHandlers.js b/client/src/components/chat-affix/registerMessagingSocketHandlers.js index d5519661e..accee896b 100644 --- a/client/src/components/chat-affix/registerMessagingSocketHandlers.js +++ b/client/src/components/chat-affix/registerMessagingSocketHandlers.js @@ -267,7 +267,17 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho return; } - const { conversationId, type, job_conversations, messageIds, ...fields } = data; + const { + conversationId, + type, + job_conversations, + messageIds, // used by "conversation-marked-read" + messageIdsMarkedRead, // used by "conversation-marked-unread" + lastUnreadMessageId, // used by "conversation-marked-unread" + unreadCount, // used by "conversation-marked-unread" + ...fields + } = data; + logLocal("handleConversationChanged - Start", data); const updatedAt = new Date().toISOString(); @@ -313,15 +323,65 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho return message; }); }, + // Keep unread badge in sync (badge uses messages_aggregate.aggregate.count) messages_aggregate: () => ({ __typename: "messages_aggregate", aggregate: { __typename: "messages_aggregate_fields", count: 0 } - }) + }), + unreadcnt: () => 0 } }); } break; + case "conversation-marked-unread": { + if (!conversationId) break; + + const safeUnreadCount = typeof unreadCount === "number" ? unreadCount : 1; + const idsMarkedRead = Array.isArray(messageIdsMarkedRead) ? messageIdsMarkedRead : []; + + client.cache.modify({ + id: client.cache.identify({ __typename: "conversations", id: conversationId }), + fields: { + // Bubble the conversation up in the list (since UI sorts by updated_at) + updated_at: () => updatedAt, + + // If details are already cached, flip the read flags appropriately + messages(existingMessages = [], { readField }) { + if (!Array.isArray(existingMessages) || existingMessages.length === 0) return existingMessages; + + return existingMessages.map((msg) => { + const id = readField("id", msg); + + if (lastUnreadMessageId && id === lastUnreadMessageId) { + return { ...msg, read: false }; + } + + if (idsMarkedRead.includes(id)) { + return { ...msg, read: true }; + } + + return msg; + }); + }, + + // Update unread badge + messages_aggregate: () => ({ + __typename: "messages_aggregate", + aggregate: { + __typename: "messages_aggregate_fields", + count: safeUnreadCount + } + }), + + // Optional: keep legacy/parallel unread field consistent if present + unreadcnt: () => safeUnreadCount + } + }); + + break; + } + case "conversation-created": updateConversationList({ ...fields, job_conversations, updated_at: updatedAt }); break; diff --git a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx index c971c118a..a2361ab52 100644 --- a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx +++ b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx @@ -12,7 +12,7 @@ import _ from "lodash"; import { ExclamationCircleOutlined } from "@ant-design/icons"; import "./chat-conversation-list.styles.scss"; import { useQuery } from "@apollo/client"; -import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries.js"; +import { GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS } from "../../graphql/phone-number-opt-out.queries.js"; import { phone } from "phone"; import { useTranslation } from "react-i18next"; import { selectBodyshop } from "../../redux/user/user.selectors"; @@ -29,13 +29,26 @@ const mapDispatchToProps = (dispatch) => ({ function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) { const { t } = useTranslation(); const [, forceUpdate] = useState(false); - const phoneNumbers = conversationList.map((item) => phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, "")); - const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, { + + const phoneNumbers = useMemo(() => { + return (conversationList || []) + .map((item) => { + try { + const p = phone(item.phone_num, "CA")?.phoneNumber; + return p ? p.replace(/^\+1/, "") : null; + } catch { + return null; + } + }) + .filter(Boolean); + }, [conversationList]); + + const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS, { variables: { - bodyshopid: bodyshop.id, + bodyshopid: bodyshop?.id, phone_numbers: phoneNumbers }, - skip: !conversationList.length, + skip: !bodyshop?.id || phoneNumbers.length === 0, fetchPolicy: "cache-and-network" }); @@ -58,15 +71,25 @@ function ChatConversationListComponent({ conversationList, selectedConversation, return _.orderBy(conversationList, ["updated_at"], ["desc"]); }, [conversationList]); - const renderConversation = (index, t) => { + const renderConversation = (index) => { const item = sortedConversationList[index]; - const normalizedPhone = phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""); - const hasOptOutEntry = optOutMap.has(normalizedPhone); + + const normalizedPhone = (() => { + try { + return phone(item.phone_num, "CA")?.phoneNumber?.replace(/^\+1/, "") || ""; + } catch { + return ""; + } + })(); + + const hasOptOutEntry = normalizedPhone ? optOutMap.has(normalizedPhone) : false; + const cardContentRight = {item.updated_at}; const cardContentLeft = item.job_conversations.length > 0 ? item.job_conversations.map((j, idx) => {j.job.ro_number}) : null; + const names = <>{_.uniq(item.job_conversations.map((j) => OwnerNameDisplayFunction(j.job)))}; const cardTitle = ( <> @@ -80,9 +103,10 @@ function ChatConversationListComponent({ conversationList, selectedConversation, )} ); + const cardExtra = ( <> - + {hasOptOutEntry && ( }> @@ -92,6 +116,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation, )} ); + const getCardStyle = () => item.id === selectedConversation ? { backgroundColor: "var(--card-selected-bg)" } @@ -104,24 +129,8 @@ function ChatConversationListComponent({ conversationList, selectedConversation, className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`} > -
- {cardContentLeft} -
-
- {cardContentRight} -
+
{cardContentLeft}
+
{cardContentRight}
); @@ -131,7 +140,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
renderConversation(index, t)} + itemContent={(index) => renderConversation(index)} style={{ height: "100%", width: "100%" }} />
diff --git a/client/src/components/chat-conversation-title/chat-conversation-title.component.jsx b/client/src/components/chat-conversation-title/chat-conversation-title.component.jsx index 86b8cca60..9c3bbaf11 100644 --- a/client/src/components/chat-conversation-title/chat-conversation-title.component.jsx +++ b/client/src/components/chat-conversation-title/chat-conversation-title.component.jsx @@ -5,24 +5,24 @@ import ChatConversationTitleTags from "../chat-conversation-title-tags/chat-conv import ChatLabelComponent from "../chat-label/chat-label.component"; import ChatPrintButton from "../chat-print-button/chat-print-button.component"; import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container"; -import { createStructuredSelector } from "reselect"; -import { connect } from "react-redux"; +import ChatMarkUnreadButton from "../chat-mark-unread-button/chat-mark-unread-button.component"; -const mapStateToProps = createStructuredSelector({}); - -const mapDispatchToProps = () => ({}); - -export function ChatConversationTitle({ conversation }) { +export function ChatConversationTitle({ conversation, onMarkUnread, markUnreadDisabled, markUnreadLoading }) { return ( {conversation?.phone_num} + + + + + ); } -export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationTitle); +export default ChatConversationTitle; diff --git a/client/src/components/chat-conversation/chat-conversation.component.jsx b/client/src/components/chat-conversation/chat-conversation.component.jsx index f5812e34f..c43443812 100644 --- a/client/src/components/chat-conversation/chat-conversation.component.jsx +++ b/client/src/components/chat-conversation/chat-conversation.component.jsx @@ -19,7 +19,9 @@ export function ChatConversationComponent({ conversation, messages, handleMarkConversationAsRead, - bodyshop + handleMarkLastMessageAsUnread, + markingAsUnreadInProgress, + canMarkUnread }) { const [loading, error] = subState; @@ -33,7 +35,12 @@ export function ChatConversationComponent({ onMouseDown={handleMarkConversationAsRead} onKeyDown={handleMarkConversationAsRead} > - + diff --git a/client/src/components/chat-conversation/chat-conversation.container.jsx b/client/src/components/chat-conversation/chat-conversation.container.jsx index e53ec8172..ed90b9053 100644 --- a/client/src/components/chat-conversation/chat-conversation.container.jsx +++ b/client/src/components/chat-conversation/chat-conversation.container.jsx @@ -1,6 +1,6 @@ import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client"; import axios from "axios"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries"; @@ -18,8 +18,8 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) { const client = useApolloClient(); const { socket } = useSocket(); const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false); + const [markingAsUnreadInProgress, setMarkingAsUnreadInProgress] = useState(false); - // Fetch conversation details const { loading: convoLoading, error: convoError, @@ -27,24 +27,23 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) { } = useQuery(GET_CONVERSATION_DETAILS, { variables: { conversationId: selectedConversation }, fetchPolicy: "network-only", - nextFetchPolicy: "network-only" + nextFetchPolicy: "network-only", + skip: !selectedConversation }); - // Subscription for conversation updates + const conversation = convoData?.conversations_by_pk; + + // Subscription for conversation updates (used when socket is NOT connected) useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, { - skip: socket?.connected, + skip: socket?.connected || !selectedConversation, variables: { conversationId: selectedConversation }, onData: ({ data: subscriptionResult, client }) => { - // Extract the messages array from the result const messages = subscriptionResult?.data?.messages; - if (!messages || messages.length === 0) { - console.warn("No messages found in subscription result."); - return; - } + if (!messages || messages.length === 0) return; messages.forEach((message) => { const messageRef = client.cache.identify(message); - // Write the new message to the cache + client.cache.writeFragment({ id: messageRef, fragment: gql` @@ -64,7 +63,6 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) { data: message }); - // Update the conversation cache to include the new message client.cache.modify({ id: client.cache.identify({ __typename: "conversations", id: selectedConversation }), fields: { @@ -82,6 +80,28 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) { } }); + /** + * Best-effort badge update: + * This assumes your list query uses messages_aggregate.aggregate.count as UNREAD inbound count. + * If it’s total messages, rename/create a dedicated unread aggregate in the list query and update that field instead. + */ + const setConversationUnreadCountBestEffort = useCallback( + (conversationId, unreadCount) => { + if (!conversationId) return; + + client.cache.modify({ + id: client.cache.identify({ __typename: "conversations", id: conversationId }), + fields: { + messages_aggregate(existing) { + if (!existing?.aggregate) return existing; + return { ...existing, aggregate: { ...existing.aggregate, count: unreadCount } }; + } + } + }); + }, + [client.cache] + ); + const updateCacheWithReadMessages = useCallback( (conversationId, messageIds) => { if (!conversationId || !messageIds?.length) return; @@ -89,13 +109,34 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) { messageIds.forEach((messageId) => { client.cache.modify({ id: client.cache.identify({ __typename: "messages", id: messageId }), - fields: { - read: () => true - } + fields: { read: () => true } }); }); + + setConversationUnreadCountBestEffort(conversationId, 0); }, - [client.cache] + [client.cache, setConversationUnreadCountBestEffort] + ); + + const applyUnreadStateWithMaxOneUnread = useCallback( + ({ conversationId, lastUnreadMessageId, messageIdsMarkedRead = [], unreadCount = 1 }) => { + if (!conversationId || !lastUnreadMessageId) return; + + messageIdsMarkedRead.forEach((id) => { + client.cache.modify({ + id: client.cache.identify({ __typename: "messages", id }), + fields: { read: () => true } + }); + }); + + client.cache.modify({ + id: client.cache.identify({ __typename: "messages", id: lastUnreadMessageId }), + fields: { read: () => false } + }); + + setConversationUnreadCountBestEffort(conversationId, unreadCount); + }, + [client.cache, setConversationUnreadCountBestEffort] ); // WebSocket event handlers @@ -103,20 +144,25 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) { if (!socket?.connected) return; const handleConversationChange = (data) => { - if (data.type === "conversation-marked-read") { - const { conversationId, messageIds } = data; - updateCacheWithReadMessages(conversationId, messageIds); + if (data?.type === "conversation-marked-read") { + updateCacheWithReadMessages(data.conversationId, data.messageIds); + } + + if (data?.type === "conversation-marked-unread") { + applyUnreadStateWithMaxOneUnread({ + conversationId: data.conversationId, + lastUnreadMessageId: data.lastUnreadMessageId, + messageIdsMarkedRead: data.messageIdsMarkedRead, + unreadCount: data.unreadCount + }); } }; socket.on("conversation-changed", handleConversationChange); + return () => socket.off("conversation-changed", handleConversationChange); + }, [socket, updateCacheWithReadMessages, applyUnreadStateWithMaxOneUnread]); - return () => { - socket.off("conversation-changed", handleConversationChange); - }; - }, [socket, updateCacheWithReadMessages]); - - // Join and leave conversation via WebSocket + // Join/leave conversation via WebSocket useEffect(() => { if (!socket?.connected || !selectedConversation || !bodyshop?.id) return; @@ -133,15 +179,21 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) { }; }, [socket, bodyshop, selectedConversation]); - // Mark conversation as read - const handleMarkConversationAsRead = async () => { - if (!convoData || markingAsReadInProgress) return; + const inboundNonSystemMessages = useMemo(() => { + const msgs = conversation?.messages || []; + return msgs + .filter((m) => m && !m.isoutbound && !m.is_system) + .slice() + .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + }, [conversation?.messages]); - const conversation = convoData.conversations_by_pk; - if (!conversation) return; + const canMarkUnread = inboundNonSystemMessages.length > 0; + + const handleMarkConversationAsRead = async () => { + if (!conversation || markingAsReadInProgress) return; const unreadMessageIds = conversation.messages - ?.filter((message) => !message.read && !message.isoutbound) + ?.filter((message) => !message.read && !message.isoutbound && !message.is_system) .map((message) => message.id); if (unreadMessageIds?.length > 0) { @@ -162,12 +214,48 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) { } }; + const handleMarkLastMessageAsUnread = async () => { + if (!conversation || markingAsUnreadInProgress) return; + if (!bodyshop?.id || !bodyshop?.imexshopid) return; + + const lastInbound = inboundNonSystemMessages[inboundNonSystemMessages.length - 1]; + if (!lastInbound?.id) return; + + setMarkingAsUnreadInProgress(true); + try { + const res = await axios.post("/sms/markLastMessageUnread", { + conversationId: conversation.id, + imexshopid: bodyshop.imexshopid, + bodyshopid: bodyshop.id + }); + + const payload = res?.data || {}; + if (payload.lastUnreadMessageId) { + applyUnreadStateWithMaxOneUnread({ + conversationId: conversation.id, + lastUnreadMessageId: payload.lastUnreadMessageId, + messageIdsMarkedRead: payload.messageIdsMarkedRead || [], + unreadCount: typeof payload.unreadCount === "number" ? payload.unreadCount : 1 + }); + } else { + setConversationUnreadCountBestEffort(conversation.id, 0); + } + } catch (error) { + console.error("Error marking last message unread:", error.message); + } finally { + setMarkingAsUnreadInProgress(false); + } + }; + return ( ); } diff --git a/client/src/components/chat-mark-unread-button/chat-mark-unread-button.component.jsx b/client/src/components/chat-mark-unread-button/chat-mark-unread-button.component.jsx new file mode 100644 index 000000000..47c14b9cf --- /dev/null +++ b/client/src/components/chat-mark-unread-button/chat-mark-unread-button.component.jsx @@ -0,0 +1,23 @@ +import { MailOutlined } from "@ant-design/icons"; +import { Button, Tooltip } from "antd"; +import { useTranslation } from "react-i18next"; + +export default function ChatMarkUnreadButton({ disabled, loading, onMarkUnread }) { + const { t } = useTranslation(); + + return ( + + diff --git a/server.js b/server.js index 4ae883717..ad829d67c 100644 --- a/server.js +++ b/server.js @@ -38,6 +38,7 @@ const { registerCleanupTask, initializeCleanupManager } = require("./server/util const { loadEmailQueue } = require("./server/notifications/queues/emailQueue"); const { loadAppQueue } = require("./server/notifications/queues/appQueue"); +const { loadFcmQueue } = require("./server/notifications/queues/fcmQueue"); const CLUSTER_RETRY_BASE_DELAY = 100; const CLUSTER_RETRY_MAX_DELAY = 5000; @@ -355,9 +356,10 @@ 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([ + const [notificationsEmailsQueue, notificationsAppQueue, notificationsFcmQueue] = await Promise.all([ loadEmailQueue(queueSettings), - loadAppQueue(queueSettings) + loadAppQueue(queueSettings), + loadFcmQueue(queueSettings) ]); // Add error listeners or other setup for queues if needed @@ -368,6 +370,10 @@ const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => { notificationsAppQueue.on("error", (error) => { logger.log(`Error in notificationsAppQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message }); }); + + notificationsFcmQueue.on("error", (error) => { + logger.log(`Error in notificationsFCMQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message }); + }); }; /** diff --git a/server/notifications/eventHandlers.js b/server/notifications/eventHandlers.js index 87a1dceed..178f41ae2 100644 --- a/server/notifications/eventHandlers.js +++ b/server/notifications/eventHandlers.js @@ -205,9 +205,8 @@ const handleTaskSocketEmit = (req) => { * @returns {Promise} JSON response with a success message. */ const handleTasksChange = async (req, res) => { - // Handle Notification Event - processNotificationEvent(req, res, "req.body.event.new.jobid", "Tasks Notifications Event Handled."); handleTaskSocketEmit(req); + return processNotificationEvent(req, res, "req.body.event.new.jobid", "Tasks Notifications Event Handled."); }; /** diff --git a/server/notifications/queues/appQueue.js b/server/notifications/queues/appQueue.js index b376b2cf6..9009ad8ed 100644 --- a/server/notifications/queues/appQueue.js +++ b/server/notifications/queues/appQueue.js @@ -42,6 +42,13 @@ const buildNotificationContent = (notifications) => { }; }; +/** + * Convert MS to S + * @param ms + * @returns {number} + */ +const seconds = (ms) => Math.max(1, Math.ceil(ms / 1000)); + /** * Initializes the notification queues and workers for adding and consolidating notifications. */ @@ -52,6 +59,13 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { devDebugLogger(`Initializing Notifications Queues with prefix: ${prefix}`); + // Redis key helpers (per jobId) + const recipientsSetKey = (jobId) => `app:${devKey}:recipients:${jobId}`; // set of `${user}:${bodyShopId}` + const recipientAssocHashKey = (jobId) => `app:${devKey}:recipientAssoc:${jobId}`; // hash `${user}:${bodyShopId}` => associationId + const consolidateFlagKey = (jobId) => `app:${devKey}:consolidate:${jobId}`; + const lockKeyForJob = (jobId) => `lock:${devKey}:consolidate:${jobId}`; + const listKey = ({ jobId, user, bodyShopId }) => `app:${devKey}:notifications:${jobId}:${user}:${bodyShopId}`; + addQueue = new Queue("notificationsAdd", { prefix, connection: pubClient, @@ -70,27 +84,39 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { const { jobId, key, variables, recipients, body, jobRoNumber } = job.data; devDebugLogger(`Adding notifications for jobId ${jobId}`); - const redisKeyPrefix = `app:${devKey}: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); - devDebugLogger(`Stored notification for ${user} under ${userKey}: ${JSON.stringify(notifications)}`); + // Store notifications atomically (RPUSH) and store recipients in a Redis set + for (const recipient of recipients || []) { + const { user, bodyShopId, associationId } = recipient; + if (!user || !bodyShopId) continue; + + const rk = `${user}:${bodyShopId}`; + + // (1) Store notification payload in a list (atomic append) + const lk = listKey({ jobId, user, bodyShopId }); + await pubClient.rpush(lk, JSON.stringify(notification)); + await pubClient.expire(lk, seconds(NOTIFICATION_STORAGE_EXPIRATION)); + + // (2) Track recipients in a set, and associationId in a hash + await pubClient.sadd(recipientsSetKey(jobId), rk); + await pubClient.expire(recipientsSetKey(jobId), seconds(NOTIFICATION_STORAGE_EXPIRATION)); + + if (associationId) { + await pubClient.hset(recipientAssocHashKey(jobId), rk, String(associationId)); + } + await pubClient.expire(recipientAssocHashKey(jobId), seconds(NOTIFICATION_STORAGE_EXPIRATION)); } - const consolidateKey = `app:${devKey}:consolidate:${jobId}`; - const flagSet = await pubClient.setnx(consolidateKey, "pending"); + // Schedule consolidation once per jobId + const flagKey = consolidateFlagKey(jobId); + const flagSet = await pubClient.setnx(flagKey, "pending"); devDebugLogger(`Consolidation flag set for jobId ${jobId}: ${flagSet}`); if (flagSet) { await consolidateQueue.add( "consolidate-notifications", - { jobId, recipients }, + { jobId }, { jobId: `consolidate-${jobId}`, delay: APP_CONSOLIDATION_DELAY, @@ -98,8 +124,9 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { backoff: LOCK_EXPIRATION } ); + + await pubClient.expire(flagKey, seconds(CONSOLIDATION_FLAG_EXPIRATION)); devDebugLogger(`Scheduled consolidation for jobId ${jobId}`); - await pubClient.expire(consolidateKey, CONSOLIDATION_FLAG_EXPIRATION / 1000); } else { devDebugLogger(`Consolidation already scheduled for jobId ${jobId}`); } @@ -114,122 +141,163 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { const consolidateWorker = new Worker( "notificationsConsolidate", async (job) => { - const { jobId, recipients } = job.data; + const { jobId } = job.data; devDebugLogger(`Consolidating notifications for jobId ${jobId}`); - const redisKeyPrefix = `app:${devKey}:notifications:${jobId}`; - const lockKey = `lock:${devKey}:consolidate:${jobId}`; - - const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", LOCK_EXPIRATION / 1000); + const lockKey = lockKeyForJob(jobId); + const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", seconds(LOCK_EXPIRATION)); devDebugLogger(`Lock acquisition for jobId ${jobId}: ${lockAcquired}`); - if (lockAcquired) { - try { - const allNotifications = {}; - const uniqueUsers = [...new Set(recipients.map((r) => r.user))]; - devDebugLogger(`Unique users for jobId ${jobId}: ${uniqueUsers}`); - - for (const user of uniqueUsers) { - const userKey = `${redisKeyPrefix}:${user}`; - const notifications = await pubClient.get(userKey); - devDebugLogger(`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); - devDebugLogger(`Deleted Redis key ${userKey}`); - } else { - devDebugLogger(`No notifications found for ${user} under ${userKey}`); - } - } - - devDebugLogger(`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 - }); - devDebugLogger( - `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 - }); - }); - devDebugLogger( - `Sent ${notifications.length} consolidated notifications to ${user} for jobId ${jobId} with notificationId ${notificationId}` - ); - } else { - devDebugLogger(`No socket IDs found for ${user} in bodyShopId ${bodyShopId}`); - } - } - } - - await pubClient.del(`app:${devKey}:consolidate:${jobId}`); - } catch (err) { - logger.log(`app-queue-consolidation-error`, "ERROR", "notifications", "api", { - message: err?.message, - stack: err?.stack - }); - throw err; - } finally { - await pubClient.del(lockKey); - } - } else { + if (!lockAcquired) { devDebugLogger(`Skipped consolidation for jobId ${jobId} - lock held by another worker`); + return; + } + + try { + const rkSet = recipientsSetKey(jobId); + const assocHash = recipientAssocHashKey(jobId); + + const recipientKeys = await pubClient.smembers(rkSet); + if (!recipientKeys?.length) { + devDebugLogger(`No recipients found for jobId ${jobId}, nothing to consolidate.`); + await pubClient.del(consolidateFlagKey(jobId)); + return; + } + + const assocMap = await pubClient.hgetall(assocHash); + + // Collect notifications by recipientKey + const notificationsByRecipient = new Map(); // rk => parsed notifications array + + for (const rk of recipientKeys) { + const [user, bodyShopId] = rk.split(":"); + const lk = listKey({ jobId, user, bodyShopId }); + + const items = await pubClient.lrange(lk, 0, -1); + if (!items?.length) continue; + + const parsed = items + .map((x) => { + try { + return JSON.parse(x); + } catch { + return null; + } + }) + .filter(Boolean); + + if (parsed.length) { + notificationsByRecipient.set(rk, parsed); + } + + // Cleanup list key after reading + await pubClient.del(lk); + } + + if (!notificationsByRecipient.size) { + devDebugLogger(`No notifications found in lists for jobId ${jobId}, nothing to insert/emit.`); + await pubClient.del(rkSet); + await pubClient.del(assocHash); + await pubClient.del(consolidateFlagKey(jobId)); + return; + } + + // Build DB inserts + const inserts = []; + const insertMeta = []; // keep rk + associationId to emit after insert + + for (const [rk, notifications] of notificationsByRecipient.entries()) { + const associationId = assocMap?.[rk]; + + // If your DB requires associationid NOT NULL, skip if missing + if (!associationId) { + devDebugLogger(`Skipping insert for ${rk} (missing associationId).`); + continue; + } + + const { scenario_text, fcm_text, scenario_meta } = buildNotificationContent(notifications); + + inserts.push({ + jobid: jobId, + associationid: associationId, + // NOTE: if these are jsonb columns, remove JSON.stringify and pass arrays directly. + scenario_text: JSON.stringify(scenario_text), + fcm_text, + scenario_meta: JSON.stringify(scenario_meta) + }); + + insertMeta.push({ rk, associationId }); + } + + // Map notificationId by associationId from Hasura returning rows + const idByAssociationId = new Map(); + + if (inserts.length > 0) { + const insertResponse = await graphQLClient.request(INSERT_NOTIFICATIONS_MUTATION, { objects: inserts }); + + const returning = insertResponse?.insert_notifications?.returning || []; + returning.forEach((row) => { + // Expecting your mutation to return associationid as well as id. + // If your mutation currently doesn’t return associationid, update it. + if (row?.associationid) idByAssociationId.set(String(row.associationid), row.id); + }); + + devDebugLogger( + `Inserted ${insertResponse.insert_notifications.affected_rows} notifications for jobId ${jobId}` + ); + } + + // Emit via Socket.io + // Group by user to reduce mapping lookups + const uniqueUsers = [...new Set(insertMeta.map(({ rk }) => rk.split(":")[0]))]; + + for (const user of uniqueUsers) { + const userMapping = await redisHelpers.getUserSocketMapping(user); + const entriesForUser = insertMeta + .map((m) => ({ ...m, user: m.rk.split(":")[0], bodyShopId: m.rk.split(":")[1] })) + .filter((m) => m.user === user); + + for (const entry of entriesForUser) { + const { rk, bodyShopId, associationId } = entry; + const notifications = notificationsByRecipient.get(rk) || []; + if (!notifications.length) continue; + + const jobRoNumber = notifications[0]?.jobRoNumber; + const notificationId = idByAssociationId.get(String(associationId)) || null; + + if (userMapping && userMapping[bodyShopId]?.socketIds) { + userMapping[bodyShopId].socketIds.forEach((socketId) => { + ioRedis.to(socketId).emit("notification", { + jobId, + jobRoNumber, + bodyShopId, + notifications, + notificationId, + associationId + }); + }); + + devDebugLogger( + `Sent ${notifications.length} consolidated notifications to ${user} for jobId ${jobId} (notificationId ${notificationId})` + ); + } else { + devDebugLogger(`No socket IDs found for ${user} in bodyShopId ${bodyShopId}`); + } + } + } + + // Cleanup recipient tracking keys + consolidation flag + await pubClient.del(rkSet); + await pubClient.del(assocHash); + await pubClient.del(consolidateFlagKey(jobId)); + } catch (err) { + logger.log("app-queue-consolidation-error", "ERROR", "notifications", "api", { + message: err?.message, + stack: err?.stack + }); + throw err; + } finally { + await pubClient.del(lockKey); } }, { @@ -244,13 +312,14 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { consolidateWorker.on("completed", (job) => devDebugLogger(`Consolidate job ${job.id} completed`)); addWorker.on("failed", (job, err) => - logger.log(`app-queue-notification-error`, "ERROR", "notifications", "api", { + logger.log("app-queue-notification-error", "ERROR", "notifications", "api", { message: err?.message, stack: err?.stack }) ); + consolidateWorker.on("failed", (job, err) => - logger.log(`app-queue-consolidation-failed:`, "ERROR", "notifications", "api", { + logger.log("app-queue-consolidation-failed", "ERROR", "notifications", "api", { message: err?.message, stack: err?.stack }) @@ -285,11 +354,13 @@ const dispatchAppsToQueue = async ({ appsToDispatch }) => { 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()}` } ); + devDebugLogger(`Added notification to queue for jobId ${jobId} with ${recipients.length} recipients`); } }; diff --git a/server/notifications/queues/emailQueue.js b/server/notifications/queues/emailQueue.js index a5ad8a530..76d368c9f 100644 --- a/server/notifications/queues/emailQueue.js +++ b/server/notifications/queues/emailQueue.js @@ -75,7 +75,9 @@ const loadEmailQueue = async ({ pubClient, logger }) => { await pubClient.hsetnx(detailsKey, "lastName", lastName || ""); await pubClient.hsetnx(detailsKey, "bodyShopTimezone", bodyShopTimezone); await pubClient.expire(detailsKey, NOTIFICATION_EXPIRATION / 1000); - await pubClient.sadd(`email:${devKey}:recipients:${jobId}`, user); + const recipientsSetKey = `email:${devKey}:recipients:${jobId}`; + await pubClient.sadd(recipientsSetKey, user); + await pubClient.expire(recipientsSetKey, NOTIFICATION_EXPIRATION / 1000); devDebugLogger(`Stored message for ${user} under ${userKey}: ${body}`); } @@ -239,7 +241,13 @@ const dispatchEmailsToQueue = async ({ emailsToDispatch, logger }) => { const emailAddQueue = getQueue(); for (const email of emailsToDispatch) { - const { jobId, jobRoNumber, bodyShopName, bodyShopTimezone, body, recipients } = email; + const { jobId, bodyShopName, bodyShopTimezone, body, recipients } = email; + let { jobRoNumber } = email; + + // Make sure Jobs that have not been coverted yet can still get notifications + if (jobRoNumber === null) { + jobRoNumber = "N/A"; + } if (!jobId || !jobRoNumber || !bodyShopName || !body || !recipients.length) { devDebugLogger( diff --git a/server/notifications/queues/fcmQueue.js b/server/notifications/queues/fcmQueue.js new file mode 100644 index 000000000..118676075 --- /dev/null +++ b/server/notifications/queues/fcmQueue.js @@ -0,0 +1,286 @@ +const { Queue, Worker } = require("bullmq"); +const { registerCleanupTask } = require("../../utils/cleanupManager"); +const getBullMQPrefix = require("../../utils/getBullMQPrefix"); +const devDebugLogger = require("../../utils/devDebugLogger"); + +const FCM_CONSOLIDATION_DELAY_IN_MINS = (() => { + const envValue = process.env?.FCM_CONSOLIDATION_DELAY_IN_MINS; + const parsedValue = envValue ? parseInt(envValue, 10) : NaN; + return isNaN(parsedValue) ? 3 : Math.max(1, parsedValue); +})(); + +const FCM_CONSOLIDATION_DELAY = FCM_CONSOLIDATION_DELAY_IN_MINS * 60000; + +// pegged constants (pattern matches your other queues) +const CONSOLIDATION_KEY_EXPIRATION = FCM_CONSOLIDATION_DELAY * 1.5; +const LOCK_EXPIRATION = FCM_CONSOLIDATION_DELAY * 0.25; +const RATE_LIMITER_DURATION = FCM_CONSOLIDATION_DELAY * 0.1; +const NOTIFICATION_EXPIRATION = FCM_CONSOLIDATION_DELAY * 1.5; + +let fcmAddQueue; +let fcmConsolidateQueue; +let fcmAddWorker; +let fcmConsolidateWorker; + +// IMPORTANT: do NOT require firebase-handler at module load time. +// firebase-handler does `require(process.env.FIREBASE_ADMINSDK_JSON)` at top-level, +// which will hard-crash environments that don’t have Firebase configured. +const hasFirebaseEnv = () => Boolean(process.env.FIREBASE_ADMINSDK_JSON && process.env.FIREBASE_DATABASE_URL); + +/** + * Get the Firebase Admin SDK, or null if Firebase is not configured. + * @returns {{app: app, remoteConfig: ((app?: App) => remoteConfig.RemoteConfig) | remoteConfig, firestore: ((app?: App) => FirebaseFirestore.Firestore) | firestore, AppOptions: AppOptions, auth: ((app?: App) => auth.Auth) | auth, securityRules: ((app?: App) => securityRules.SecurityRules) | securityRules, installations: ((app?: App) => installations.Installations) | installations, FirebaseArrayIndexError: FirebaseArrayIndexError, storage: ((app?: App) => storage.Storage) | storage, appCheck: ((app?: App) => appCheck.AppCheck) | appCheck, initializeApp(options?: AppOptions, name?: string): app.App, FirebaseError: FirebaseError, messaging: ((app?: App) => messaging.Messaging) | messaging, projectManagement: ((app?: App) => projectManagement.ProjectManagement) | projectManagement, database: ((app?: App) => database.Database) | database, machineLearning: ((app?: App) => machineLearning.MachineLearning) | machineLearning, instanceId: ((app?: App) => instanceId.InstanceId) | instanceId, SDK_VERSION: string, apps: (app.App | null)[], credential: credential, ServiceAccount: ServiceAccount, GoogleOAuthAccessToken: GoogleOAuthAccessToken}|null} + */ +const getFirebaseAdmin = () => { + if (!hasFirebaseEnv()) return null; + const { admin } = require("../../firebase/firebase-handler"); + return admin; +}; + +/** + * Get the FCM topic name for an association. + * @param associationId + * @returns {`assoc-${string}-notifications`} + */ +const topicForAssociation = (associationId) => `assoc-${associationId}-notifications`; + +/** + * Build a summary string for FCM push notification body. + * @param count + * @param jobRoNumber + * @param bodyShopName + * @returns {`${string} ${string} for ${string|string}${string|string}`} + */ +const buildPushSummary = ({ count, jobRoNumber, bodyShopName }) => { + const updates = count === 1 ? "update" : "updates"; + const ro = jobRoNumber ? `RO ${jobRoNumber}` : "a job"; + const shop = bodyShopName ? ` at ${bodyShopName}` : ""; + return `${count} ${updates} for ${ro}${shop}`; +}; + +/** + * Loads the FCM notification queues and workers. + * @param pubClient + * @param logger + * @returns {Promise, ExtractResultType, ExtractNameType>|null>} + */ +const loadFcmQueue = async ({ pubClient, logger }) => { + if (!hasFirebaseEnv()) { + devDebugLogger("FCM queue not initialized (Firebase env not configured)."); + return null; + } + + if (!fcmAddQueue || !fcmConsolidateQueue) { + const prefix = getBullMQPrefix(); + const devKey = process.env?.NODE_ENV === "production" ? "prod" : "dev"; + + devDebugLogger(`Initializing FCM Queues with prefix: ${prefix}`); + + fcmAddQueue = new Queue("fcmAdd", { + prefix, + connection: pubClient, + defaultJobOptions: { removeOnComplete: true, removeOnFail: true } + }); + + fcmConsolidateQueue = new Queue("fcmConsolidate", { + prefix, + connection: pubClient, + defaultJobOptions: { removeOnComplete: true, removeOnFail: true } + }); + + fcmAddWorker = new Worker( + "fcmAdd", + async (job) => { + const { jobId, jobRoNumber, bodyShopId, bodyShopName, scenarioKey, key, variables, body, recipients } = + job.data; + devDebugLogger(`Adding FCM notifications for jobId ${jobId}`); + + const redisKeyPrefix = `fcm:${devKey}:notifications:${jobId}`; + + for (const r of recipients) { + const associationId = r?.associationId; + if (!associationId) continue; + + const assocKey = `${redisKeyPrefix}:${associationId}`; + const payload = JSON.stringify({ + body: body || "", + scenarioKey: scenarioKey || "", + key: key || "", + variables: variables || {}, + ts: Date.now() + }); + + await pubClient.rpush(assocKey, payload); + await pubClient.expire(assocKey, NOTIFICATION_EXPIRATION / 1000); + const recipientsSetKey = `fcm:${devKey}:recipients:${jobId}`; + await pubClient.sadd(recipientsSetKey, associationId); + await pubClient.expire(recipientsSetKey, NOTIFICATION_EXPIRATION / 1000); + + // store some metadata once per jobId + const metaKey = `fcm:${devKey}:meta:${jobId}`; + await pubClient.hsetnx(metaKey, "jobRoNumber", jobRoNumber || ""); + await pubClient.hsetnx(metaKey, "bodyShopId", bodyShopId || ""); + await pubClient.hsetnx(metaKey, "bodyShopName", bodyShopName || ""); + await pubClient.expire(metaKey, NOTIFICATION_EXPIRATION / 1000); + } + + const consolidateKey = `fcm:${devKey}:consolidate:${jobId}`; + const flagSet = await pubClient.setnx(consolidateKey, "pending"); + + if (flagSet) { + await fcmConsolidateQueue.add( + "consolidate-fcm", + { jobId }, + { + jobId: `consolidate-${jobId}`, + delay: FCM_CONSOLIDATION_DELAY, + attempts: 3, + backoff: LOCK_EXPIRATION + } + ); + await pubClient.expire(consolidateKey, CONSOLIDATION_KEY_EXPIRATION / 1000); + devDebugLogger(`Scheduled FCM consolidation for jobId ${jobId}`); + } else { + devDebugLogger(`FCM consolidation already scheduled for jobId ${jobId}`); + } + }, + { prefix, connection: pubClient, concurrency: 5 } + ); + + fcmConsolidateWorker = new Worker( + "fcmConsolidate", + async (job) => { + const { jobId } = job.data; + const devKey = process.env?.NODE_ENV === "production" ? "prod" : "dev"; + + const lockKey = `lock:${devKey}:fcmConsolidate:${jobId}`; + const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", LOCK_EXPIRATION / 1000); + + if (!lockAcquired) { + devDebugLogger(`Skipped FCM consolidation for jobId ${jobId} - lock held by another worker`); + return; + } + + try { + const admin = getFirebaseAdmin(); + if (!admin) { + devDebugLogger("FCM consolidation skipped (Firebase not available)."); + return; + } + + const recipientsSet = `fcm:${devKey}:recipients:${jobId}`; + const associationIds = await pubClient.smembers(recipientsSet); + + const metaKey = `fcm:${devKey}:meta:${jobId}`; + const meta = await pubClient.hgetall(metaKey); + const jobRoNumber = meta?.jobRoNumber || ""; + const bodyShopId = meta?.bodyShopId || ""; + const bodyShopName = meta?.bodyShopName || ""; + + for (const associationId of associationIds) { + const assocKey = `fcm:${devKey}:notifications:${jobId}:${associationId}`; + const messages = await pubClient.lrange(assocKey, 0, -1); + + if (!messages?.length) continue; + + const count = messages.length; + const notificationBody = buildPushSummary({ count, jobRoNumber, bodyShopName }); + + const topic = topicForAssociation(associationId); + + // FCM "data" values MUST be strings + await admin.messaging().send({ + topic, + notification: { + title: "ImEX Online", + body: notificationBody + }, + data: { + type: "job-notification", + jobId: String(jobId), + jobRoNumber: String(jobRoNumber || ""), + bodyShopId: String(bodyShopId || ""), + bodyShopName: String(bodyShopName || ""), + associationId: String(associationId), + count: String(count) + }, + android: { priority: "high" }, + apns: { headers: { "apns-priority": "10" } } + }); + + devDebugLogger(`Sent FCM push to topic ${topic} for jobId ${jobId} (${count} updates)`); + + await pubClient.del(assocKey); + } + + await pubClient.del(recipientsSet); + await pubClient.del(metaKey); + await pubClient.del(`fcm:${devKey}:consolidate:${jobId}`); + } catch (err) { + logger.log("fcm-queue-consolidation-error", "ERROR", "notifications", "api", { + message: err?.message, + stack: err?.stack + }); + throw err; + } finally { + await pubClient.del(lockKey); + } + }, + { prefix, connection: pubClient, concurrency: 1, limiter: { max: 1, duration: RATE_LIMITER_DURATION } } + ); + + fcmAddWorker.on("failed", (job, err) => + logger.log("fcm-add-failed", "ERROR", "notifications", "api", { message: err?.message, stack: err?.stack }) + ); + + fcmConsolidateWorker.on("failed", (job, err) => + logger.log("fcm-consolidate-failed", "ERROR", "notifications", "api", { + message: err?.message, + stack: err?.stack + }) + ); + + const shutdown = async () => { + devDebugLogger("Closing FCM queue workers..."); + await Promise.all([fcmAddWorker.close(), fcmConsolidateWorker.close()]); + devDebugLogger("FCM queue workers closed"); + }; + + registerCleanupTask(shutdown); + } + + return fcmAddQueue; +}; + +/** + * Get the FCM add queue. + * @returns {*} + */ +const getQueue = () => { + if (!fcmAddQueue) throw new Error("FCM add queue not initialized. Ensure loadFcmQueue is called during bootstrap."); + return fcmAddQueue; +}; + +/** + * Dispatch FCM notifications to the FCM add queue. + * @param fcmsToDispatch + * @returns {Promise} + */ +const dispatchFcmsToQueue = async ({ fcmsToDispatch }) => { + if (!hasFirebaseEnv()) return; + const queue = getQueue(); + + for (const fcm of fcmsToDispatch) { + const { jobId, jobRoNumber, bodyShopId, bodyShopName, scenarioKey, key, variables, body, recipients } = fcm; + + if (!jobId || !recipients?.length) continue; + + await queue.add( + "add-fcm-notification", + { jobId, jobRoNumber, bodyShopId, bodyShopName, scenarioKey, key, variables, body, recipients }, + { jobId: `${jobId}-${Date.now()}` } + ); + } +}; + +module.exports = { loadFcmQueue, getQueue, dispatchFcmsToQueue }; diff --git a/server/notifications/scenarioBuilders.js b/server/notifications/scenarioBuilders.js index ec24e1804..d94c5390b 100644 --- a/server/notifications/scenarioBuilders.js +++ b/server/notifications/scenarioBuilders.js @@ -19,6 +19,8 @@ const buildNotification = (data, key, body, variables = {}) => { jobId: data.jobId, jobRoNumber: data.jobRoNumber, bodyShopId: data.bodyShopId, + scenarioKey: data.scenarioKey, + scenarioTable: data.scenarioTable, key, body, variables, @@ -32,21 +34,47 @@ const buildNotification = (data, key, body, variables = {}) => { body, recipients: [] }, - fcm: { recipients: [] } + fcm: { + jobId: data.jobId, + jobRoNumber: data.jobRoNumber, + bodyShopId: data.bodyShopId, + bodyShopName: data.bodyShopName, + bodyShopTimezone: data.bodyShopTimezone, + scenarioKey: data.scenarioKey, + scenarioTable: data.scenarioTable, + key, + body, + variables, + recipients: [] + } }; // Populate recipients from scenarioWatchers data.scenarioWatchers.forEach((recipients) => { const { user, app, fcm, email, firstName, lastName, employeeId, associationId } = recipients; - if (app === true) + + 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 }); + } + + if (email === true) { + result.email.recipients.push({ user, firstName, lastName }); + } + + if (fcm === true) { + // Keep structure consistent and future-proof (token lookup is done server-side) + result.fcm.recipients.push({ + user, + bodyShopId: data.bodyShopId, + employeeId, + associationId + }); + } }); return result; diff --git a/server/notifications/scenarioParser.js b/server/notifications/scenarioParser.js index aebec8205..7e6a211ad 100644 --- a/server/notifications/scenarioParser.js +++ b/server/notifications/scenarioParser.js @@ -14,6 +14,7 @@ const { isEmpty, isFunction } = require("lodash"); const { getMatchingScenarios } = require("./scenarioMapper"); const { dispatchEmailsToQueue } = require("./queues/emailQueue"); const { dispatchAppsToQueue } = require("./queues/appQueue"); +const { dispatchFcmsToQueue } = require("./queues/fcmQueue"); // NEW // If true, the user who commits the action will NOT receive notifications; if false, they will. const FILTER_SELF_FROM_WATCHERS = process.env?.FILTER_SELF_FROM_WATCHERS !== "false"; @@ -298,6 +299,16 @@ const scenarioParser = async (req, jobIdField) => { }) ); } + + const fcmsToDispatch = scenariosToDispatch.map((scenario) => scenario?.fcm); + if (!isEmpty(fcmsToDispatch)) { + dispatchFcmsToQueue({ fcmsToDispatch, logger }).catch((e) => + logger.log("Something went wrong dispatching FCMs to the FCM Notification Queue", "error", "queue", null, { + message: e?.message, + stack: e?.stack + }) + ); + } }; module.exports = scenarioParser; From 021bf714d689ed07c55b32218a7e2bfa769da05c Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Mon, 5 Jan 2026 13:14:33 -0800 Subject: [PATCH 24/32] IO-3431 Job Image Gallery Signed-off-by: Allan Carr --- .../local-media-grid.component.jsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/client/src/components/jobs-documents-local-gallery/local-media-grid.component.jsx b/client/src/components/jobs-documents-local-gallery/local-media-grid.component.jsx index f4c671f4f..4a3c6700b 100644 --- a/client/src/components/jobs-documents-local-gallery/local-media-grid.component.jsx +++ b/client/src/components/jobs-documents-local-gallery/local-media-grid.component.jsx @@ -1,3 +1,4 @@ +import { Checkbox } from "antd"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; /** @@ -7,18 +8,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; * - images: Array<{ src, fullsize, filename?, isSelected? }> * - onToggle(index) * - onClick(index) optional for viewing + * - thumbSize: automatically set to 125 for chat, 250 for default */ export function LocalMediaGrid({ images, onToggle, onClick, - thumbSize = 100, gap = 8, minColumns = 3, maxColumns = 12, context = "default", expandHeight = false }) { + const thumbSize = context === "chat" ? 100 : 180; const containerRef = useRef(null); const [cols, setCols] = useState(() => { // Pre-calc initial columns to stabilize layout before images render @@ -133,7 +135,7 @@ export function LocalMediaGrid({ role="listitem" tabIndex={0} aria-label={img.filename || `image ${idx + 1}`} - onClick={() => onClick ? onClick(idx) : onToggle(idx)} + onClick={() => (onClick ? onClick(idx) : onToggle(idx))} onKeyDown={(e) => handleKeyDown(e, idx)} style={{ position: "relative", @@ -200,8 +202,7 @@ export function LocalMediaGrid({ /> )} {onClick && ( - { e.stopPropagation(); @@ -209,10 +210,12 @@ export function LocalMediaGrid({ }} onClick={(e) => e.stopPropagation()} style={{ - position: 'absolute', + position: "absolute", top: 5, - right: 5, - zIndex: 2 + left: 5, + zIndex: 2, + opacity: img.isSelected ? 1 : 0.4, + transition: "opacity 0.3s" }} /> )} From 4cdc15f70b0b4e3fcfbf00320854a83183c45d7b Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 5 Jan 2026 16:51:28 -0500 Subject: [PATCH 25/32] feature/IO-3492-FCM-Queue-For-Notifications: Checkpoint --- server/graphql-client/queries.js | 9 + server/notifications/queues/fcmQueue.js | 326 ++++++++++++++++++------ 2 files changed, 260 insertions(+), 75 deletions(-) diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index b51c73c49..451ec6bed 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -3187,3 +3187,12 @@ mutation INSERT_MEDIA_ANALYTICS($mediaObject: media_analytics_insert_input!) { } } `; + +exports.GET_USERS_FCM_TOKENS_BY_EMAILS = /* GraphQL */ ` + query GET_USERS_FCM_TOKENS_BY_EMAILS($emails: [String!]!) { + users(where: { email: { _in: $emails } }) { + email + fcmtokens + } + } +`; diff --git a/server/notifications/queues/fcmQueue.js b/server/notifications/queues/fcmQueue.js index 118676075..e0c7f0776 100644 --- a/server/notifications/queues/fcmQueue.js +++ b/server/notifications/queues/fcmQueue.js @@ -1,8 +1,14 @@ +// NOTE: Despite the filename, this implementation targets Expo Push Tokens (ExponentPushToken[...]). +// It does NOT use Firebase Admin and does NOT require credentials (no EXPO_ACCESS_TOKEN). + const { Queue, Worker } = require("bullmq"); const { registerCleanupTask } = require("../../utils/cleanupManager"); const getBullMQPrefix = require("../../utils/getBullMQPrefix"); const devDebugLogger = require("../../utils/devDebugLogger"); +const { client: gqlClient } = require("../../graphql-client/graphql-client"); +const { GET_USERS_FCM_TOKENS_BY_EMAILS } = require("../../graphql-client/queries"); + const FCM_CONSOLIDATION_DELAY_IN_MINS = (() => { const envValue = process.env?.FCM_CONSOLIDATION_DELAY_IN_MINS; const parsedValue = envValue ? parseInt(envValue, 10) : NaN; @@ -17,35 +23,173 @@ const LOCK_EXPIRATION = FCM_CONSOLIDATION_DELAY * 0.25; const RATE_LIMITER_DURATION = FCM_CONSOLIDATION_DELAY * 0.1; const NOTIFICATION_EXPIRATION = FCM_CONSOLIDATION_DELAY * 1.5; +const EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send"; +const EXPO_MAX_MESSAGES_PER_REQUEST = 100; + let fcmAddQueue; let fcmConsolidateQueue; let fcmAddWorker; let fcmConsolidateWorker; -// IMPORTANT: do NOT require firebase-handler at module load time. -// firebase-handler does `require(process.env.FIREBASE_ADMINSDK_JSON)` at top-level, -// which will hard-crash environments that don’t have Firebase configured. -const hasFirebaseEnv = () => Boolean(process.env.FIREBASE_ADMINSDK_JSON && process.env.FIREBASE_DATABASE_URL); +/** + * Milliseconds to seconds. + * @param ms + * @returns {number} + */ +const seconds = (ms) => Math.max(1, Math.ceil(ms / 1000)); /** - * Get the Firebase Admin SDK, or null if Firebase is not configured. - * @returns {{app: app, remoteConfig: ((app?: App) => remoteConfig.RemoteConfig) | remoteConfig, firestore: ((app?: App) => FirebaseFirestore.Firestore) | firestore, AppOptions: AppOptions, auth: ((app?: App) => auth.Auth) | auth, securityRules: ((app?: App) => securityRules.SecurityRules) | securityRules, installations: ((app?: App) => installations.Installations) | installations, FirebaseArrayIndexError: FirebaseArrayIndexError, storage: ((app?: App) => storage.Storage) | storage, appCheck: ((app?: App) => appCheck.AppCheck) | appCheck, initializeApp(options?: AppOptions, name?: string): app.App, FirebaseError: FirebaseError, messaging: ((app?: App) => messaging.Messaging) | messaging, projectManagement: ((app?: App) => projectManagement.ProjectManagement) | projectManagement, database: ((app?: App) => database.Database) | database, machineLearning: ((app?: App) => machineLearning.MachineLearning) | machineLearning, instanceId: ((app?: App) => instanceId.InstanceId) | instanceId, SDK_VERSION: string, apps: (app.App | null)[], credential: credential, ServiceAccount: ServiceAccount, GoogleOAuthAccessToken: GoogleOAuthAccessToken}|null} + * Chunk an array into smaller arrays of given size. + * @param arr + * @param size + * @returns {*[]} */ -const getFirebaseAdmin = () => { - if (!hasFirebaseEnv()) return null; - const { admin } = require("../../firebase/firebase-handler"); - return admin; +const chunk = (arr, size) => { + const out = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; }; /** - * Get the FCM topic name for an association. - * @param associationId - * @returns {`assoc-${string}-notifications`} + * Check if a string is an Expo push token. + * @param s + * @returns {boolean} */ -const topicForAssociation = (associationId) => `assoc-${associationId}-notifications`; +const isExpoPushToken = (s) => { + if (!s || typeof s !== "string") return false; + // Common formats observed in the wild: + // - ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx] + // - ExpoPushToken[xxxxxxxxxxxxxxxxxxxxxx] + return /^ExponentPushToken\[[^\]]+\]$/.test(s) || /^ExpoPushToken\[[^\]]+\]$/.test(s); +}; /** - * Build a summary string for FCM push notification body. + * Get unique, trimmed strings from an array. + * @param arr + * @returns {any[]} + */ +const uniqStrings = (arr) => [ + ...new Set( + arr + .filter(Boolean) + .map((x) => String(x).trim()) + .filter(Boolean) + ) +]; + +/** + * Normalize users.fcmtokens (jsonb) into an array of Expo push tokens. + * + * New expected shape (example): + * { + * "ExponentPushToken[dksJAdLUTofdEk7P59thue]": { + * "platform": "ios", + * "timestamp": 1767397802709, + * "pushTokenString": "ExponentPushToken[dksJAdLUTofdEk7P59thue]" + * } + * } + * + * Also supports older/alternate shapes: + * - string: "ExponentPushToken[...]" + * - array: ["ExponentPushToken[...]", ...] + * - object: token keys OR values containing token-like fields + * @param fcmtokens + * @returns {string[]|*[]} + */ +const normalizeTokens = (fcmtokens) => { + if (!fcmtokens) return []; + + if (typeof fcmtokens === "string") { + const s = fcmtokens.trim(); + return isExpoPushToken(s) ? [s] : []; + } + + if (Array.isArray(fcmtokens)) { + return uniqStrings(fcmtokens).filter(isExpoPushToken); + } + + if (typeof fcmtokens === "object") { + const keys = Object.keys(fcmtokens || {}); + const vals = Object.values(fcmtokens || {}); + + const fromKeys = keys.filter(isExpoPushToken); + + const fromValues = vals + .map((v) => { + if (!v) return null; + + // Some shapes store token as a string value directly + if (typeof v === "string") return v; + + if (typeof v === "object") { + // Your new shape uses pushTokenString + return v.pushTokenString || v.token || v.expoPushToken || null; + } + + return null; + }) + .filter(Boolean) + .map(String); + + return uniqStrings([...fromKeys, ...fromValues]).filter(isExpoPushToken); + } + + return []; +}; + +/** + * Safely parse JSON response. + * @param res + * @returns {Promise<*|null>} + */ +const safeJson = async (res) => { + try { + return await res.json(); + } catch { + return null; + } +}; + +/** + * Send Expo push notifications + * @param {Array} messages Expo messages array + * @param {Object} logger + */ +const sendExpoPush = async ({ messages, logger }) => { + if (!messages?.length) return; + + for (const batch of chunk(messages, EXPO_MAX_MESSAGES_PER_REQUEST)) { + const res = await fetch(EXPO_PUSH_ENDPOINT, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json" + }, + body: JSON.stringify(batch) + }); + + const payload = await safeJson(res); + + if (!res.ok) { + logger?.log?.("expo-push-http-error", "ERROR", "notifications", "api", { + status: res.status, + statusText: res.statusText, + payload + }); + throw new Error(`Expo push HTTP error: ${res.status} ${res.statusText}`); + } + + const data = payload?.data; + if (Array.isArray(data)) { + const errors = data.filter((t) => t?.status === "error"); + if (errors.length) { + logger?.log?.("expo-push-ticket-errors", "ERROR", "notifications", "api", { errors }); + } + } + } +}; +/** + * Build a summary string for push notification body. * @param count * @param jobRoNumber * @param bodyShopName @@ -59,22 +203,17 @@ const buildPushSummary = ({ count, jobRoNumber, bodyShopName }) => { }; /** - * Loads the FCM notification queues and workers. + * Loads the push notification queues and workers (Expo push). * @param pubClient * @param logger - * @returns {Promise, ExtractResultType, ExtractNameType>|null>} + * @returns {Promise} */ const loadFcmQueue = async ({ pubClient, logger }) => { - if (!hasFirebaseEnv()) { - devDebugLogger("FCM queue not initialized (Firebase env not configured)."); - return null; - } - if (!fcmAddQueue || !fcmConsolidateQueue) { const prefix = getBullMQPrefix(); const devKey = process.env?.NODE_ENV === "production" ? "prod" : "dev"; - devDebugLogger(`Initializing FCM Queues with prefix: ${prefix}`); + devDebugLogger(`Initializing Expo Push Queues with prefix: ${prefix}`); fcmAddQueue = new Queue("fcmAdd", { prefix, @@ -93,35 +232,40 @@ const loadFcmQueue = async ({ pubClient, logger }) => { async (job) => { const { jobId, jobRoNumber, bodyShopId, bodyShopName, scenarioKey, key, variables, body, recipients } = job.data; - devDebugLogger(`Adding FCM notifications for jobId ${jobId}`); - const redisKeyPrefix = `fcm:${devKey}:notifications:${jobId}`; + devDebugLogger(`Adding push notifications for jobId ${jobId}`); - for (const r of recipients) { + const recipientsSetKey = `fcm:${devKey}:recipients:${jobId}`; // set of user emails + const metaKey = `fcm:${devKey}:meta:${jobId}`; + const redisKeyPrefix = `fcm:${devKey}:notifications:${jobId}`; // per-user list keys + + // store job-level metadata once + await pubClient.hsetnx(metaKey, "jobRoNumber", jobRoNumber || ""); + await pubClient.hsetnx(metaKey, "bodyShopId", bodyShopId || ""); + await pubClient.hsetnx(metaKey, "bodyShopName", bodyShopName || ""); + await pubClient.expire(metaKey, seconds(NOTIFICATION_EXPIRATION)); + + for (const r of recipients || []) { + const user = r?.user; const associationId = r?.associationId; - if (!associationId) continue; - const assocKey = `${redisKeyPrefix}:${associationId}`; + if (!user) continue; + + const userKey = `${redisKeyPrefix}:${user}`; const payload = JSON.stringify({ body: body || "", scenarioKey: scenarioKey || "", key: key || "", variables: variables || {}, + associationId: associationId ? String(associationId) : null, ts: Date.now() }); - await pubClient.rpush(assocKey, payload); - await pubClient.expire(assocKey, NOTIFICATION_EXPIRATION / 1000); - const recipientsSetKey = `fcm:${devKey}:recipients:${jobId}`; - await pubClient.sadd(recipientsSetKey, associationId); - await pubClient.expire(recipientsSetKey, NOTIFICATION_EXPIRATION / 1000); + await pubClient.rpush(userKey, payload); + await pubClient.expire(userKey, seconds(NOTIFICATION_EXPIRATION)); - // store some metadata once per jobId - const metaKey = `fcm:${devKey}:meta:${jobId}`; - await pubClient.hsetnx(metaKey, "jobRoNumber", jobRoNumber || ""); - await pubClient.hsetnx(metaKey, "bodyShopId", bodyShopId || ""); - await pubClient.hsetnx(metaKey, "bodyShopName", bodyShopName || ""); - await pubClient.expire(metaKey, NOTIFICATION_EXPIRATION / 1000); + await pubClient.sadd(recipientsSetKey, user); + await pubClient.expire(recipientsSetKey, seconds(NOTIFICATION_EXPIRATION)); } const consolidateKey = `fcm:${devKey}:consolidate:${jobId}`; @@ -138,10 +282,11 @@ const loadFcmQueue = async ({ pubClient, logger }) => { backoff: LOCK_EXPIRATION } ); - await pubClient.expire(consolidateKey, CONSOLIDATION_KEY_EXPIRATION / 1000); - devDebugLogger(`Scheduled FCM consolidation for jobId ${jobId}`); + + await pubClient.expire(consolidateKey, seconds(CONSOLIDATION_KEY_EXPIRATION)); + devDebugLogger(`Scheduled consolidation for jobId ${jobId}`); } else { - devDebugLogger(`FCM consolidation already scheduled for jobId ${jobId}`); + devDebugLogger(`Consolidation already scheduled for jobId ${jobId}`); } }, { prefix, connection: pubClient, concurrency: 5 } @@ -154,63 +299,95 @@ const loadFcmQueue = async ({ pubClient, logger }) => { const devKey = process.env?.NODE_ENV === "production" ? "prod" : "dev"; const lockKey = `lock:${devKey}:fcmConsolidate:${jobId}`; - const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", LOCK_EXPIRATION / 1000); + const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", seconds(LOCK_EXPIRATION)); if (!lockAcquired) { - devDebugLogger(`Skipped FCM consolidation for jobId ${jobId} - lock held by another worker`); + devDebugLogger(`Skipped consolidation for jobId ${jobId} - lock held by another worker`); return; } try { - const admin = getFirebaseAdmin(); - if (!admin) { - devDebugLogger("FCM consolidation skipped (Firebase not available)."); + const recipientsSet = `fcm:${devKey}:recipients:${jobId}`; + const userEmails = await pubClient.smembers(recipientsSet); + + if (!userEmails?.length) { + devDebugLogger(`No recipients found for jobId ${jobId}`); + await pubClient.del(`fcm:${devKey}:consolidate:${jobId}`); return; } - const recipientsSet = `fcm:${devKey}:recipients:${jobId}`; - const associationIds = await pubClient.smembers(recipientsSet); - + // Load meta const metaKey = `fcm:${devKey}:meta:${jobId}`; const meta = await pubClient.hgetall(metaKey); const jobRoNumber = meta?.jobRoNumber || ""; const bodyShopId = meta?.bodyShopId || ""; const bodyShopName = meta?.bodyShopName || ""; - for (const associationId of associationIds) { - const assocKey = `fcm:${devKey}:notifications:${jobId}:${associationId}`; - const messages = await pubClient.lrange(assocKey, 0, -1); + // Fetch tokens for all recipients (1 DB round-trip) + const usersResp = await gqlClient.request(GET_USERS_FCM_TOKENS_BY_EMAILS, { emails: userEmails }); + const tokenMap = new Map( + (usersResp?.users || []).map((u) => [String(u.email), normalizeTokens(u.fcmtokens)]) + ); - if (!messages?.length) continue; + for (const userEmail of userEmails) { + const userKey = `fcm:${devKey}:notifications:${jobId}:${userEmail}`; + const raw = await pubClient.lrange(userKey, 0, -1); - const count = messages.length; + if (!raw?.length) { + await pubClient.del(userKey); + continue; + } + + const parsed = raw + .map((x) => { + try { + return JSON.parse(x); + } catch { + return null; + } + }) + .filter(Boolean); + + const count = parsed.length; const notificationBody = buildPushSummary({ count, jobRoNumber, bodyShopName }); - const topic = topicForAssociation(associationId); + // associationId should be stable for a user in a job’s bodyshop; take first non-null + const associationId = + parsed.find((p) => p?.associationId)?.associationId != null + ? String(parsed.find((p) => p?.associationId)?.associationId) + : ""; - // FCM "data" values MUST be strings - await admin.messaging().send({ - topic, - notification: { - title: "ImEX Online", - body: notificationBody - }, + const tokens = tokenMap.get(String(userEmail)) || []; + + if (!tokens.length) { + devDebugLogger(`No Expo push tokens for ${userEmail}; skipping push for jobId ${jobId}`); + await pubClient.del(userKey); + continue; + } + + // Build 1 message per device token + const messages = tokens.map((token) => ({ + to: token, + title: "ImEX Online", + body: notificationBody, + priority: "high", data: { type: "job-notification", jobId: String(jobId), jobRoNumber: String(jobRoNumber || ""), bodyShopId: String(bodyShopId || ""), bodyShopName: String(bodyShopName || ""), - associationId: String(associationId), + associationId: String(associationId || ""), + userEmail: String(userEmail), count: String(count) - }, - android: { priority: "high" }, - apns: { headers: { "apns-priority": "10" } } - }); + } + })); - devDebugLogger(`Sent FCM push to topic ${topic} for jobId ${jobId} (${count} updates)`); + await sendExpoPush({ messages, logger }); - await pubClient.del(assocKey); + devDebugLogger(`Sent Expo push to ${userEmail} for jobId ${jobId} (${count} updates)`); + + await pubClient.del(userKey); } await pubClient.del(recipientsSet); @@ -241,9 +418,9 @@ const loadFcmQueue = async ({ pubClient, logger }) => { ); const shutdown = async () => { - devDebugLogger("Closing FCM queue workers..."); + devDebugLogger("Closing push queue workers..."); await Promise.all([fcmAddWorker.close(), fcmConsolidateWorker.close()]); - devDebugLogger("FCM queue workers closed"); + devDebugLogger("Push queue workers closed"); }; registerCleanupTask(shutdown); @@ -253,7 +430,7 @@ const loadFcmQueue = async ({ pubClient, logger }) => { }; /** - * Get the FCM add queue. + * Get the add queue. * @returns {*} */ const getQueue = () => { @@ -262,12 +439,11 @@ const getQueue = () => { }; /** - * Dispatch FCM notifications to the FCM add queue. + * Dispatch push notifications to the add queue. * @param fcmsToDispatch * @returns {Promise} */ const dispatchFcmsToQueue = async ({ fcmsToDispatch }) => { - if (!hasFirebaseEnv()) return; const queue = getQueue(); for (const fcm of fcmsToDispatch) { From 00bf5977ae368ce9db8c41b352b30653f7f8dda7 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 5 Jan 2026 18:42:40 -0500 Subject: [PATCH 26/32] feature/IO-3492-FCM-Queue-For-Notifications: Finalize --- .../notification-settings-form.component.jsx | 12 +- server/graphql-client/queries.js | 8 + server/notifications/queues/appQueue.js | 10 +- server/notifications/queues/emailQueue.js | 28 +++- server/notifications/queues/fcmQueue.js | 147 +++++++++++++++--- 5 files changed, 170 insertions(+), 35 deletions(-) diff --git a/client/src/components/notification-settings/notification-settings-form.component.jsx b/client/src/components/notification-settings/notification-settings-form.component.jsx index 6f29c9bb5..649a0be55 100644 --- a/client/src/components/notification-settings/notification-settings-form.component.jsx +++ b/client/src/components/notification-settings/notification-settings-form.component.jsx @@ -155,8 +155,12 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => { ) - }, - { + } + ]; + + // Currently disabled for prod + if (!import.meta.env.PROD) { + columns.push({ title: setIsDirty(true)} />, dataIndex: "fcm", key: "fcm", @@ -166,8 +170,8 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => { ) - } - ]; + }); + } const dataSource = notificationScenarios.map((scenario) => ({ key: scenario })); diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 451ec6bed..c686d50eb 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -3196,3 +3196,11 @@ exports.GET_USERS_FCM_TOKENS_BY_EMAILS = /* GraphQL */ ` } } `; + +exports.UPDATE_USER_FCM_TOKENS_BY_EMAIL = /* GraphQL */ ` + mutation UPDATE_USER_FCM_TOKENS_BY_EMAIL($email: String!, $fcmtokens: jsonb) { + update_users(where: { email: { _eq: $email } }, _set: { fcmtokens: $fcmtokens }) { + affected_rows + } + } +`; diff --git a/server/notifications/queues/appQueue.js b/server/notifications/queues/appQueue.js index 9009ad8ed..4cd7777d6 100644 --- a/server/notifications/queues/appQueue.js +++ b/server/notifications/queues/appQueue.js @@ -168,6 +168,7 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { // Collect notifications by recipientKey const notificationsByRecipient = new Map(); // rk => parsed notifications array + const listKeysToDelete = []; // delete only after successful insert+emit for (const rk of recipientKeys) { const [user, bodyShopId] = rk.split(":"); @@ -188,14 +189,17 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { if (parsed.length) { notificationsByRecipient.set(rk, parsed); - } - // Cleanup list key after reading - await pubClient.del(lk); + // IMPORTANT: do NOT delete list yet; only delete after successful insert+emit + listKeysToDelete.push(lk); + } } if (!notificationsByRecipient.size) { devDebugLogger(`No notifications found in lists for jobId ${jobId}, nothing to insert/emit.`); + if (listKeysToDelete.length) { + await pubClient.del(...listKeysToDelete); + } await pubClient.del(rkSet); await pubClient.del(assocHash); await pubClient.del(consolidateFlagKey(jobId)); diff --git a/server/notifications/queues/emailQueue.js b/server/notifications/queues/emailQueue.js index 76d368c9f..a38765450 100644 --- a/server/notifications/queues/emailQueue.js +++ b/server/notifications/queues/emailQueue.js @@ -27,6 +27,16 @@ let emailConsolidateQueue; let emailAddWorker; let emailConsolidateWorker; +const seconds = (ms) => Math.max(1, Math.ceil(ms / 1000)); + +const escapeHtml = (s = "") => + String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + /** * Initializes the email notification queues and workers. * @@ -65,19 +75,21 @@ const loadEmailQueue = async ({ pubClient, logger }) => { const redisKeyPrefix = `email:${devKey}:notifications:${jobId}`; - for (const recipient of recipients) { + for (const recipient of recipients || []) { const { user, firstName, lastName } = recipient; + if (!user) continue; const userKey = `${redisKeyPrefix}:${user}`; await pubClient.rpush(userKey, body); - await pubClient.expire(userKey, NOTIFICATION_EXPIRATION / 1000); + await pubClient.expire(userKey, seconds(NOTIFICATION_EXPIRATION)); const detailsKey = `email:${devKey}:recipientDetails:${jobId}:${user}`; await pubClient.hsetnx(detailsKey, "firstName", firstName || ""); await pubClient.hsetnx(detailsKey, "lastName", lastName || ""); - await pubClient.hsetnx(detailsKey, "bodyShopTimezone", bodyShopTimezone); - await pubClient.expire(detailsKey, NOTIFICATION_EXPIRATION / 1000); + const tzValue = bodyShopTimezone || "UTC"; + await pubClient.hsetnx(detailsKey, "bodyShopTimezone", tzValue); + await pubClient.expire(detailsKey, seconds(NOTIFICATION_EXPIRATION)); const recipientsSetKey = `email:${devKey}:recipients:${jobId}`; await pubClient.sadd(recipientsSetKey, user); - await pubClient.expire(recipientsSetKey, NOTIFICATION_EXPIRATION / 1000); + await pubClient.expire(recipientsSetKey, seconds(NOTIFICATION_EXPIRATION)); devDebugLogger(`Stored message for ${user} under ${userKey}: ${body}`); } @@ -95,7 +107,7 @@ const loadEmailQueue = async ({ pubClient, logger }) => { } ); devDebugLogger(`Scheduled email consolidation for jobId ${jobId}`); - await pubClient.expire(consolidateKey, CONSOLIDATION_KEY_EXPIRATION / 1000); + await pubClient.expire(consolidateKey, seconds(CONSOLIDATION_KEY_EXPIRATION)); } else { devDebugLogger(`Email consolidation already scheduled for jobId ${jobId}`); } @@ -115,7 +127,7 @@ const loadEmailQueue = async ({ pubClient, logger }) => { devDebugLogger(`Consolidating emails for jobId ${jobId}`); const lockKey = `lock:${devKey}:emailConsolidate:${jobId}`; - const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", LOCK_EXPIRATION / 1000); + const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", seconds(LOCK_EXPIRATION)); if (lockAcquired) { try { const recipientsSet = `email:${devKey}:recipients:${jobId}`; @@ -141,7 +153,7 @@ const loadEmailQueue = async ({ pubClient, logger }) => {
    - ${messages.map((msg) => `
  • ${msg}
  • `).join("")} + ${messages.map((msg) => `
  • ${escapeHtml(msg)}
  • `).join("")}
diff --git a/server/notifications/queues/fcmQueue.js b/server/notifications/queues/fcmQueue.js index e0c7f0776..4bbd34cac 100644 --- a/server/notifications/queues/fcmQueue.js +++ b/server/notifications/queues/fcmQueue.js @@ -7,7 +7,7 @@ const getBullMQPrefix = require("../../utils/getBullMQPrefix"); const devDebugLogger = require("../../utils/devDebugLogger"); const { client: gqlClient } = require("../../graphql-client/graphql-client"); -const { GET_USERS_FCM_TOKENS_BY_EMAILS } = require("../../graphql-client/queries"); +const { GET_USERS_FCM_TOKENS_BY_EMAILS, UPDATE_USER_FCM_TOKENS_BY_EMAIL } = require("../../graphql-client/queries"); const FCM_CONSOLIDATION_DELAY_IN_MINS = (() => { const envValue = process.env?.FCM_CONSOLIDATION_DELAY_IN_MINS; @@ -19,7 +19,13 @@ const FCM_CONSOLIDATION_DELAY = FCM_CONSOLIDATION_DELAY_IN_MINS * 60000; // pegged constants (pattern matches your other queues) const CONSOLIDATION_KEY_EXPIRATION = FCM_CONSOLIDATION_DELAY * 1.5; -const LOCK_EXPIRATION = FCM_CONSOLIDATION_DELAY * 0.25; + +// IMPORTANT: lock must outlive a full consolidation run to avoid duplicate sends. +const LOCK_EXPIRATION = FCM_CONSOLIDATION_DELAY * 1.5; + +// Keep Bull backoff separate from lock TTL to avoid unexpected long retries. +const BACKOFF_DELAY = Math.max(1000, Math.floor(FCM_CONSOLIDATION_DELAY * 0.25)); + const RATE_LIMITER_DURATION = FCM_CONSOLIDATION_DELAY * 0.1; const NOTIFICATION_EXPIRATION = FCM_CONSOLIDATION_DELAY * 1.5; @@ -137,6 +143,47 @@ const normalizeTokens = (fcmtokens) => { return []; }; +/** + * Remove specified tokens from the stored fcmtokens jsonb while preserving the original shape. + * @param fcmtokens + * @param tokensToRemove + * @returns {*} + */ +const removeTokensFromFcmtokens = (fcmtokens, tokensToRemove) => { + const remove = new Set((tokensToRemove || []).map((t) => String(t).trim()).filter(Boolean)); + if (!remove.size) return fcmtokens; + if (!fcmtokens) return fcmtokens; + + if (typeof fcmtokens === "string") { + const s = fcmtokens.trim(); + return remove.has(s) ? null : fcmtokens; + } + + if (Array.isArray(fcmtokens)) { + const next = fcmtokens.filter((t) => !remove.has(String(t).trim())); + return next.length ? next : []; + } + + if (typeof fcmtokens === "object") { + const next = {}; + for (const [k, v] of Object.entries(fcmtokens)) { + const keyIsToken = isExpoPushToken(k) && remove.has(k); + + let valueToken = null; + if (typeof v === "string") valueToken = v; + else if (v && typeof v === "object") valueToken = v.pushTokenString || v.token || v.expoPushToken || null; + + const valueIsToken = valueToken && remove.has(String(valueToken).trim()); + + if (keyIsToken || valueIsToken) continue; + next[k] = v; + } + return Object.keys(next).length ? next : {}; + } + + return fcmtokens; +}; + /** * Safely parse JSON response. * @param res @@ -151,12 +198,18 @@ const safeJson = async (res) => { }; /** - * Send Expo push notifications + * Send Expo push notifications. + * Returns invalid tokens that should be removed (e.g., DeviceNotRegistered). + * * @param {Array} messages Expo messages array * @param {Object} logger + * @returns {Promise<{invalidTokens: string[], ticketIds: string[]}>} */ const sendExpoPush = async ({ messages, logger }) => { - if (!messages?.length) return; + if (!messages?.length) return { invalidTokens: [], ticketIds: [] }; + + const invalidTokens = new Set(); + const ticketIds = []; for (const batch of chunk(messages, EXPO_MAX_MESSAGES_PER_REQUEST)) { const res = await fetch(EXPO_PUSH_ENDPOINT, { @@ -179,15 +232,43 @@ const sendExpoPush = async ({ messages, logger }) => { throw new Error(`Expo push HTTP error: ${res.status} ${res.statusText}`); } - const data = payload?.data; - if (Array.isArray(data)) { - const errors = data.filter((t) => t?.status === "error"); - if (errors.length) { - logger?.log?.("expo-push-ticket-errors", "ERROR", "notifications", "api", { errors }); + const tickets = Array.isArray(payload?.data) ? payload.data : payload?.data ? [payload.data] : []; + + if (!tickets.length) { + logger?.log?.("expo-push-bad-response", "ERROR", "notifications", "api", { payload }); + continue; + } + + // Expo returns tickets in the same order as messages in the request batch + for (let i = 0; i < tickets.length; i++) { + const t = tickets[i]; + const msg = batch[i]; + const token = typeof msg?.to === "string" ? msg.to : null; + + if (t?.status === "ok" && t?.id) ticketIds.push(String(t.id)); + + if (t?.status === "error") { + const errCode = t?.details?.error; + const msgText = String(t?.message || ""); + + const shouldDelete = + errCode === "DeviceNotRegistered" || /not a registered push notification recipient/i.test(msgText); + + if (shouldDelete && token && isExpoPushToken(token)) { + invalidTokens.add(token); + } + + logger?.log?.("expo-push-ticket-error", "ERROR", "notifications", "api", { + token, + ticket: t + }); } } } + + return { invalidTokens: [...invalidTokens], ticketIds }; }; + /** * Build a summary string for push notification body. * @param count @@ -239,10 +320,10 @@ const loadFcmQueue = async ({ pubClient, logger }) => { const metaKey = `fcm:${devKey}:meta:${jobId}`; const redisKeyPrefix = `fcm:${devKey}:notifications:${jobId}`; // per-user list keys - // store job-level metadata once - await pubClient.hsetnx(metaKey, "jobRoNumber", jobRoNumber || ""); - await pubClient.hsetnx(metaKey, "bodyShopId", bodyShopId || ""); - await pubClient.hsetnx(metaKey, "bodyShopName", bodyShopName || ""); + // Store job-level metadata (always keep latest values) + await pubClient.hset(metaKey, "jobRoNumber", jobRoNumber || ""); + await pubClient.hset(metaKey, "bodyShopId", bodyShopId || ""); + await pubClient.hset(metaKey, "bodyShopName", bodyShopName || ""); await pubClient.expire(metaKey, seconds(NOTIFICATION_EXPIRATION)); for (const r of recipients || []) { @@ -279,7 +360,7 @@ const loadFcmQueue = async ({ pubClient, logger }) => { jobId: `consolidate-${jobId}`, delay: FCM_CONSOLIDATION_DELAY, attempts: 3, - backoff: LOCK_EXPIRATION + backoff: BACKOFF_DELAY } ); @@ -325,8 +406,13 @@ const loadFcmQueue = async ({ pubClient, logger }) => { // Fetch tokens for all recipients (1 DB round-trip) const usersResp = await gqlClient.request(GET_USERS_FCM_TOKENS_BY_EMAILS, { emails: userEmails }); + + // Map: email -> { raw, tokens } const tokenMap = new Map( - (usersResp?.users || []).map((u) => [String(u.email), normalizeTokens(u.fcmtokens)]) + (usersResp?.users || []).map((u) => [ + String(u.email), + { raw: u.fcmtokens, tokens: normalizeTokens(u.fcmtokens) } + ]) ); for (const userEmail of userEmails) { @@ -352,12 +438,12 @@ const loadFcmQueue = async ({ pubClient, logger }) => { const notificationBody = buildPushSummary({ count, jobRoNumber, bodyShopName }); // associationId should be stable for a user in a job’s bodyshop; take first non-null + const firstWithAssociation = parsed.find((p) => p?.associationId != null); const associationId = - parsed.find((p) => p?.associationId)?.associationId != null - ? String(parsed.find((p) => p?.associationId)?.associationId) - : ""; + firstWithAssociation?.associationId != null ? String(firstWithAssociation.associationId) : ""; - const tokens = tokenMap.get(String(userEmail)) || []; + const tokenInfo = tokenMap.get(String(userEmail)) || { raw: null, tokens: [] }; + const tokens = tokenInfo.tokens || []; if (!tokens.length) { devDebugLogger(`No Expo push tokens for ${userEmail}; skipping push for jobId ${jobId}`); @@ -383,7 +469,28 @@ const loadFcmQueue = async ({ pubClient, logger }) => { } })); - await sendExpoPush({ messages, logger }); + const { invalidTokens } = await sendExpoPush({ messages, logger }); + + // Opportunistic cleanup: remove invalid tokens from users.fcmtokens + if (invalidTokens?.length) { + try { + const nextFcmtokens = removeTokensFromFcmtokens(tokenInfo.raw, invalidTokens); + + await gqlClient.request(UPDATE_USER_FCM_TOKENS_BY_EMAIL, { + email: String(userEmail), + fcmtokens: nextFcmtokens + }); + + devDebugLogger(`Cleaned ${invalidTokens.length} invalid Expo tokens for ${userEmail}`); + } catch (e) { + logger?.log?.("expo-push-token-cleanup-failed", "ERROR", "notifications", "api", { + userEmail: String(userEmail), + message: e?.message, + stack: e?.stack + }); + // Do not throw: cleanup failure should not retry the whole consolidation and risk duplicate pushes. + } + } devDebugLogger(`Sent Expo push to ${userEmail} for jobId ${jobId} (${count} updates)`); From e26df780bf6c7d83fed8c8a84b52ed22740351ef Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 7 Jan 2026 13:21:31 -0500 Subject: [PATCH 27/32] feature/IO-3494-Change-Preferred-Contact - Implement select box, fix capture bug --- .../jobs-create-owner-info.new.component.jsx | 23 +++++++++++++------ .../owner-detail-form.component.jsx | 20 ++++++++++------ .../jobs-create/jobs-create.container.jsx | 13 +++++++---- client/src/translations/en_us/common.json | 5 +++- client/src/translations/es/common.json | 5 +++- client/src/translations/fr/common.json | 5 +++- client/src/utils/phoneTypeOptions.js | 13 +++++++++++ 7 files changed, 63 insertions(+), 21 deletions(-) create mode 100644 client/src/utils/phoneTypeOptions.js diff --git a/client/src/components/jobs-create-owner-info/jobs-create-owner-info.new.component.jsx b/client/src/components/jobs-create-owner-info/jobs-create-owner-info.new.component.jsx index 5509415a1..b4dc3302a 100644 --- a/client/src/components/jobs-create-owner-info/jobs-create-owner-info.new.component.jsx +++ b/client/src/components/jobs-create-owner-info/jobs-create-owner-info.new.component.jsx @@ -5,16 +5,25 @@ import JobCreateContext from "../../pages/jobs-create/jobs-create.context"; import FormItemEmail from "../form-items-formatted/email-form-item.component"; import FormItemPhone, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; +import { buildOwnerPhoneTypeOptions } from "../../utils/phoneTypeOptions.js"; export default function JobsCreateOwnerInfoNewComponent() { - const PHONE_TYPE_OPTIONS = [ - { label: t("owners.labels.home"), value: "Home" }, - { label: t("owners.labels.work"), value: "Work" }, - { label: t("owners.labels.cell"), value: "Cell" } - ]; const [state] = useContext(JobCreateContext); - const { t } = useTranslation(); + + const PHONE_TYPE_OPTIONS = buildOwnerPhoneTypeOptions(t); + + const PREFERRED_CONTACT_OPTIONS = [ + { + label: t("owners.labels.email", { defaultValue: "Email" }), + options: [{ label: t("owners.labels.email", { defaultValue: "Email" }), value: "Email" }] + }, + { + label: t("owners.labels.phone", { defaultValue: "Phone" }), + options: PHONE_TYPE_OPTIONS + } + ]; + return (
@@ -162,7 +171,7 @@ export default function JobsCreateOwnerInfoNewComponent() { - + + diff --git a/client/src/pages/jobs-create/jobs-create.container.jsx b/client/src/pages/jobs-create/jobs-create.container.jsx index 9905a72fc..cc3aa888d 100644 --- a/client/src/pages/jobs-create/jobs-create.container.jsx +++ b/client/src/pages/jobs-create/jobs-create.container.jsx @@ -47,7 +47,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr const [form] = Form.useForm(); const [state, setState] = contextState; const [insertJob] = useMutation(INSERT_NEW_JOB); - const [loadOwner, RemoteOwnerData] = useLazyQuery(QUERY_OWNER_FOR_JOB_CREATION); + const [loadOwner, remoteOwnerData] = useLazyQuery(QUERY_OWNER_FOR_JOB_CREATION); useEffect(() => { if (state.owner.selectedid) { @@ -116,15 +116,20 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr let ownerData; if (!job.ownerid) { - ownerData = job.owner.data; - ownerData.shopid = bodyshop.id; + // Keep preferred_contact for the nested owner insert... + job.owner.data.shopid = bodyshop.id; + + // ...but do NOT flatten preferred_contact into the job row. + ownerData = _.cloneDeep(job.owner.data); delete ownerData.preferred_contact; + delete job.ownerid; } else { - ownerData = _.cloneDeep(RemoteOwnerData.data.owners_by_pk); + ownerData = _.cloneDeep(remoteOwnerData.data.owners_by_pk); delete ownerData.id; delete ownerData.__typename; } + if (!state.vehicle.none) { if (!job.vehicleid) { delete job.vehicleid; diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 12265673b..cd2024170 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -2598,7 +2598,10 @@ "updateowner": "Update Owner", "work": "Work", "home": "Home", - "cell": "Cell" + "cell": "Cell", + "other": "Other", + "email": "Email", + "phone": "Phone" }, "successes": { "delete": "Owner deleted successfully.", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 3ed9d6e12..28f352c6c 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -2598,7 +2598,10 @@ "updateowner": "", "work": "", "home": "", - "cell": "" + "cell": "", + "other": "", + "email": "", + "phone": "" }, "successes": { "delete": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 93af4bff0..754cb74be 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -2598,7 +2598,10 @@ "updateowner": "", "work": "", "home": "", - "cell": "" + "cell": "", + "other": "", + "email": "", + "phone": "" }, "successes": { "delete": "", diff --git a/client/src/utils/phoneTypeOptions.js b/client/src/utils/phoneTypeOptions.js new file mode 100644 index 000000000..d1f19ac48 --- /dev/null +++ b/client/src/utils/phoneTypeOptions.js @@ -0,0 +1,13 @@ +export const OWNER_PHONE_TYPE_VALUES = { + HOME: "Home", + WORK: "Work", + CELL: "Cell", + OTHER: "Other" +}; + +export const buildOwnerPhoneTypeOptions = (t) => [ + { label: t("owners.labels.home"), value: OWNER_PHONE_TYPE_VALUES.HOME }, + { label: t("owners.labels.work"), value: OWNER_PHONE_TYPE_VALUES.WORK }, + { label: t("owners.labels.cell"), value: OWNER_PHONE_TYPE_VALUES.CELL }, + { label: t("owners.labels.other"), value: OWNER_PHONE_TYPE_VALUES.OTHER } +]; From 5efd9e43bea48527caa717b45e73255f686b299d Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 7 Jan 2026 15:28:32 -0500 Subject: [PATCH 28/32] feature/IO-3494-Change-Preferred-Contact - Implement select box, fix capture bug --- .../jobs-create-owner-info.new.component.jsx | 4 ++++ client/src/translations/en_us/common.json | 3 ++- client/src/translations/es/common.json | 3 ++- client/src/translations/fr/common.json | 3 ++- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/client/src/components/jobs-create-owner-info/jobs-create-owner-info.new.component.jsx b/client/src/components/jobs-create-owner-info/jobs-create-owner-info.new.component.jsx index b4dc3302a..2792adcdc 100644 --- a/client/src/components/jobs-create-owner-info/jobs-create-owner-info.new.component.jsx +++ b/client/src/components/jobs-create-owner-info/jobs-create-owner-info.new.component.jsx @@ -18,6 +18,10 @@ export default function JobsCreateOwnerInfoNewComponent() { label: t("owners.labels.email", { defaultValue: "Email" }), options: [{ label: t("owners.labels.email", { defaultValue: "Email" }), value: "Email" }] }, + { + label: t("owners.labels.sms", { defaultValue: "SMS" }), + options: [{ label: t("owners.labels.sms", { defaultValue: "SMS" }), value: "SMS" }] + }, { label: t("owners.labels.phone", { defaultValue: "Phone" }), options: PHONE_TYPE_OPTIONS diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index cd2024170..e2ff97a7f 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -2601,7 +2601,8 @@ "cell": "Cell", "other": "Other", "email": "Email", - "phone": "Phone" + "phone": "Phone", + "sms": "SMS" }, "successes": { "delete": "Owner deleted successfully.", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 28f352c6c..95416363e 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -2601,7 +2601,8 @@ "cell": "", "other": "", "email": "", - "phone": "" + "phone": "", + "sms": "" }, "successes": { "delete": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 754cb74be..999ef1865 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -2601,7 +2601,8 @@ "cell": "", "other": "", "email": "", - "phone": "" + "phone": "", + "sms": "" }, "successes": { "delete": "", From 9b62633ba6716e59eb75d6d307543583cb8fe1ce Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Thu, 8 Jan 2026 11:59:38 -0800 Subject: [PATCH 29/32] IO-3431 Fix Document in Drawer from Production Board Signed-off-by: Allan Carr --- .../job-detail-cards.documents.component.jsx | 23 ++++--- ...ts-imgproxy-gallery.external.component.jsx | 61 ++----------------- .../production-list-detail.component.jsx | 2 +- 3 files changed, 22 insertions(+), 64 deletions(-) diff --git a/client/src/components/job-detail-cards/job-detail-cards.documents.component.jsx b/client/src/components/job-detail-cards/job-detail-cards.documents.component.jsx index 984a64ca0..786d8b598 100644 --- a/client/src/components/job-detail-cards/job-detail-cards.documents.component.jsx +++ b/client/src/components/job-detail-cards/job-detail-cards.documents.component.jsx @@ -1,13 +1,21 @@ import { Carousel } from "antd"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; -import { GenerateThumbUrl } from "../jobs-documents-gallery/job-documents.utility"; +import { fetchImgproxyThumbnails } from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component"; import UpsellComponent, { upsellEnum } from "../upsell/upsell.component"; import CardTemplate from "./job-detail-cards.template.component"; export default function JobDetailCardsDocumentsComponent({ loading, data, bodyshop }) { const { t } = useTranslation(); const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" }); + const [thumbnails, setThumbnails] = useState([]); + + useEffect(() => { + if (data?.id) { + fetchImgproxyThumbnails({ setStateCallback: setThumbnails, jobId: data.id, imagesOnly: true }); + } + }, [data?.id]); if (!data) return ( @@ -22,18 +30,19 @@ export default function JobDetailCardsDocumentsComponent({ loading, data, bodysh title={t("jobs.labels.cards.documents")} extraLink={`/manage/jobs/${data.id}?tab=documents`} > - {!hasMediaAccess && ( - - {data.documents.length > 0 ? ( + {!hasMediaAccess && } + {hasMediaAccess && ( + <> + {thumbnails.length > 0 ? ( - {data.documents.map((item) => ( - {item.name} + {thumbnails.map((item) => ( + {item.filename} ))} ) : (
{t("documents.errors.nodocuments")}
)} -
+ )} ); diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component.jsx index 884ac5ac3..badee7414 100644 --- a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component.jsx +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component.jsx @@ -1,75 +1,24 @@ -import { useEffect, useMemo, useState, useCallback } from "react"; -import axios from "axios"; +import { useEffect, useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component"; +import { fetchImgproxyThumbnails } from "./jobs-documents-imgproxy-gallery.component"; function JobsDocumentImgproxyGalleryExternal({ jobId, externalMediaState, context = "chat" }) { const [galleryImages, setgalleryImages] = externalMediaState; - const [rawMedia, setRawMedia] = useState([]); const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); const fetchThumbnails = useCallback(async () => { - const result = await axios.post("/media/imgproxy/thumbnails", { jobid: jobId }); - return result.data; - }, [jobId]); + await fetchImgproxyThumbnails({ setStateCallback: setgalleryImages, jobId, imagesOnly: true }); + }, [jobId, setgalleryImages]); useEffect(() => { if (!jobId) return; setIsLoading(true); - fetchThumbnails() - .then(setRawMedia) - .catch(console.error) - .finally(() => setIsLoading(false)); + fetchThumbnails().finally(() => setIsLoading(false)); }, [jobId, fetchThumbnails]); - const documents = useMemo(() => { - return rawMedia - .filter((v) => v.type?.startsWith("image")) - .map((v) => ({ - src: v.thumbnailUrl, - thumbnail: v.thumbnailUrl, - fullsize: v.originalUrl, - width: 225, - height: 225, - thumbnailWidth: 225, - thumbnailHeight: 225, - caption: v.key, - filename: v.key, - // additional properties if needed - key: v.key, - id: v.id, - type: v.type, - size: v.size, - extension: v.extension - })); - }, [rawMedia]); - - useEffect(() => { - const prevSelection = new Map(galleryImages.map((p) => [p.filename, p.isSelected])); - const nextImages = documents.map((d) => ({ ...d, isSelected: prevSelection.get(d.filename) || false })); - // Micro-optimization: if array length and each filename + selection flag match, skip creating a new array. - if (galleryImages.length === nextImages.length) { - let identical = true; - for (let i = 0; i < nextImages.length; i++) { - if ( - galleryImages[i].filename !== nextImages[i].filename || - galleryImages[i].isSelected !== nextImages[i].isSelected - ) { - identical = false; - break; - } - } - if (identical) { - setIsLoading(false); // ensure loading stops even on no-change - return; - } - } - setgalleryImages(nextImages); - setIsLoading(false); // stop loading after transform regardless of emptiness - }, [documents, setgalleryImages, galleryImages, jobId]); - const handleToggle = useCallback( (idx) => { setgalleryImages((imgs) => imgs.map((g, gIdx) => (gIdx === idx ? { ...g, isSelected: !g.isSelected } : g))); diff --git a/client/src/components/production-list-detail/production-list-detail.component.jsx b/client/src/components/production-list-detail/production-list-detail.component.jsx index 307e0409d..0bcb36a13 100644 --- a/client/src/components/production-list-detail/production-list-detail.component.jsx +++ b/client/src/components/production-list-detail/production-list-detail.component.jsx @@ -199,7 +199,7 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te {!bodyshop.uselocalmediaserver && ( <>
- + )}
From a906bc5816a8f560a06f89378855f5b88fd884b1 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Thu, 8 Jan 2026 12:20:15 -0800 Subject: [PATCH 30/32] IO-3496 Phase 1 for Job-Total-USA Fix Signed-off-by: Allan Carr --- server/job/job-totals-USA.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/server/job/job-totals-USA.js b/server/job/job-totals-USA.js index d1ff5abe6..7f9abb0a2 100644 --- a/server/job/job-totals-USA.js +++ b/server/job/job-totals-USA.js @@ -381,7 +381,12 @@ async function CalculateRatesTotals({ job, client }) { if (item.mod_lbr_ty) { //Check to see if it has 0 hours and a price instead. - if (item.lbr_op === "OP14" && item.act_price > 0 && (!item.part_type || item.mod_lb_hrs === 0) && !IsAdditionalCost(item)) { + if ( + item.lbr_op === "OP14" && + item.act_price > 0 && + (!item.part_type || item.mod_lb_hrs === 0) && + !IsAdditionalCost(item) + ) { //Scenario where SGI may pay out hours using a part price. if (!ret[item.mod_lbr_ty.toLowerCase()].total) { ret[item.mod_lbr_ty.toLowerCase()].base = Dinero(); @@ -943,9 +948,10 @@ function CalculateTaxesTotals(job, otherTotals) { amount: Math.round(stlTowing.t_amt * 100) }) ); + if (stlStorage) - taxableAmounts.TOW = taxableAmounts.TOW.add( - (taxableAmounts.TOW = Dinero({ + taxableAmounts.STOR = taxableAmounts.STOR.add( + (taxableAmounts.STOR = Dinero({ amount: Math.round(stlStorage.t_amt * 100) })) ); @@ -988,7 +994,7 @@ function CalculateTaxesTotals(job, otherTotals) { const pfo = job.cieca_pfo; Object.keys(taxableAmounts).map((key) => { try { - if (key.startsWith("PA")) { + if (key.startsWith("PA") && key !== "PAE") { const typeOfPart = key; // === "PAM" ? "PAC" : key; //At least one of these scenarios must be taxable. for (let tyCounter = 1; tyCounter <= 5; tyCounter++) { From 6c9dd969e507d7705b1b80b652d0f04670e1cfa0 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Thu, 8 Jan 2026 15:34:48 -0800 Subject: [PATCH 31/32] IO-3496 STOR Job Total USA Signed-off-by: Allan Carr --- server/graphql-client/queries.js | 1 + server/job/job-totals-USA.js | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index b51c73c49..4894cc37b 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -1506,6 +1506,7 @@ exports.GET_JOB_BY_PK = `query GET_JOB_BY_PK($id: uuid!) { est_ct_fn shopid est_ct_ln + ciecaid cieca_pfl cieca_pft cieca_pfo diff --git a/server/job/job-totals-USA.js b/server/job/job-totals-USA.js index 7f9abb0a2..6d5c7c8b4 100644 --- a/server/job/job-totals-USA.js +++ b/server/job/job-totals-USA.js @@ -948,6 +948,12 @@ function CalculateTaxesTotals(job, otherTotals) { amount: Math.round(stlTowing.t_amt * 100) }) ); + if (!stlTowing && !job.ciecaid && job.towing_payable) + taxableAmounts.TOW = taxableAmounts.TOW.add( + Dinero({ + amount: Math.round((job.towing_payable || 0) * 100) + }) + ); if (stlStorage) taxableAmounts.STOR = taxableAmounts.STOR.add( @@ -956,6 +962,13 @@ function CalculateTaxesTotals(job, otherTotals) { })) ); + if (!stlStorage && !job.ciecaid && job.storage_payable) + taxableAmounts.STOR = taxableAmounts.STOR.add( + Dinero({ + amount: Math.round((job.storage_payable || 0) * 100) + }) + ); + const pfp = job.parts_tax_rates; //For any profile level markups/discounts, add them in now as well. From 2015e88a279f8af041c04438c108c36bf1ac13e2 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 9 Jan 2026 14:17:56 -0500 Subject: [PATCH 32/32] release/2025-12-19 - Hardened --- .../chat-popup/chat-popup.component.jsx | 105 ++++++++++++------ 1 file changed, 73 insertions(+), 32 deletions(-) diff --git a/client/src/components/chat-popup/chat-popup.component.jsx b/client/src/components/chat-popup/chat-popup.component.jsx index 01c5b3e85..95513e09c 100644 --- a/client/src/components/chat-popup/chat-popup.component.jsx +++ b/client/src/components/chat-popup/chat-popup.component.jsx @@ -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 { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; @@ -27,32 +27,52 @@ const mapDispatchToProps = (dispatch) => ({ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) { const { t } = useTranslation(); - const [pollInterval, setPollInterval] = useState(0); const { socket } = useSocket(); - const client = useApolloClient(); // Apollo Client instance for cache operations + const client = useApolloClient(); - // Lazy query for conversations - const [getConversations, { loading, data, refetch }] = useLazyQuery(CONVERSATION_LIST_QUERY, { + // When socket is connected, we do NOT poll (socket should push updates). + // When disconnected, we poll as a fallback. + const [pollInterval, setPollInterval] = useState(0); + + // Ensure conversations query runs once on initial page load (component mount). + const hasLoadedConversationsOnceRef = useRef(false); + + // Preserve the last known unread aggregate count so the badge doesn't "vanish" + // when UNREAD_CONVERSATION_COUNT gets skipped after socket connects. + const [unreadAggregateCount, setUnreadAggregateCount] = useState(0); + + // Lazy query for conversations (executed manually) + const [getConversations, { loading, data, refetch, called }] = useLazyQuery(CONVERSATION_LIST_QUERY, { fetchPolicy: "network-only", nextFetchPolicy: "network-only", - skip: !chatVisible, + notifyOnNetworkStatusChange: true, ...(pollInterval > 0 ? { pollInterval } : {}) }); - // Query for unread count when chat is not visible - const { data: unreadData } = useQuery(UNREAD_CONVERSATION_COUNT, { + // Query for unread count when chat is not visible and socket is not connected. + // (Once socket connects, we stop this query; we keep the last known value in state.) + useQuery(UNREAD_CONVERSATION_COUNT, { fetchPolicy: "network-only", nextFetchPolicy: "network-only", - pollInterval: 60 * 1000 // TODO: This is a fix for now, should be coming from sockets + skip: chatVisible || socket?.connected, + pollInterval: socket?.connected ? 0 : 60 * 1000, + onCompleted: (result) => { + const nextCount = result?.messages_aggregate?.aggregate?.count; + if (typeof nextCount === "number") setUnreadAggregateCount(nextCount); + }, + onError: (err) => { + // Keep last known count; do not force badge to zero on transient failures + console.warn("UNREAD_CONVERSATION_COUNT failed:", err?.message || err); + } }); - // Socket connection status + // Socket connection status -> polling strategy for CONVERSATION_LIST_QUERY useEffect(() => { const handleSocketStatus = () => { if (socket?.connected) { - setPollInterval(15 * 60 * 1000); // 15 minutes + setPollInterval(0); // skip polling if socket connected } else { - setPollInterval(60 * 1000); // 60 seconds + setPollInterval(60 * 1000); // fallback polling if disconnected } }; @@ -71,19 +91,32 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh }; }, [socket]); - // Fetch conversations when chat becomes visible + // Run conversations query exactly once on initial load (component mount) useEffect(() => { - if (chatVisible) - getConversations({ - variables: { - offset: 0 - } - }).catch((err) => { - console.error(`Error fetching conversations: ${(err, err.message || "")}`); - }); - }, [chatVisible, getConversations]); + if (hasLoadedConversationsOnceRef.current) return; - // Get unread count from the cache + hasLoadedConversationsOnceRef.current = true; + + getConversations({ + variables: { offset: 0 } + }).catch((err) => { + console.error(`Error fetching conversations: ${err?.message || ""}`, err); + }); + }, [getConversations]); + + const handleManualRefresh = async () => { + try { + if (called && typeof refetch === "function") { + await refetch({ offset: 0 }); + } else { + await getConversations({ variables: { offset: 0 } }); + } + } catch (err) { + console.error(`Error refreshing conversations: ${err?.message || ""}`, err); + } + }; + + // Get unread count from the cache (preferred). Fallback to preserved aggregate count. const unreadCount = (() => { try { const cachedData = client.readQuery({ @@ -91,18 +124,23 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh variables: { offset: 0 } }); - if (!cachedData?.conversations) { - return unreadData?.messages_aggregate?.aggregate?.count; + const conversations = cachedData?.conversations; + + if (!Array.isArray(conversations) || conversations.length === 0) { + return unreadAggregateCount; } - // Aggregate unread message count - return cachedData.conversations.reduce((total, conversation) => { - const unread = conversation.messages_aggregate?.aggregate?.count || 0; + const hasUnreadCounts = conversations.some((c) => c?.messages_aggregate?.aggregate?.count != null); + if (!hasUnreadCounts) { + return unreadAggregateCount; + } + + return 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 + } catch { + return unreadAggregateCount; } })(); @@ -117,9 +155,12 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh - refetch()} /> + + + {!socket?.connected && {t("messaging.labels.nopush")}} + toggleChatVisible()} style={{ position: "absolute", right: ".5rem", top: ".5rem" }}