From f55764e859f7f96a78d4ad2a9a479d0f1a3b18a7 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 9 Apr 2025 14:56:49 -0400 Subject: [PATCH 001/195] feature/IO-2282-VSSTA-Integration: - Boilerplate in new route - Fix issues with imgproxy - Clean up imgproxy --- ...nt-imgproxy-gallery.download.component.jsx | 10 +- ...s-documents-imgproxy-gallery.component.jsx | 2 +- server.js | 1 + server/accounting/pbs/pbs-ap-allocations.js | 2 +- server/accounting/pbs/pbs-job-export.js | 4 +- server/graphql-client/graphql-client.js | 14 ++- server/integrations/VSSTA/vsstaIntegration.js | 36 ++++++ server/integrations/VSSTA/vsstaMiddleware.js | 5 + server/media/imgproxy-media.js | 114 ++++++++++-------- server/media/media.js | 25 ++-- server/media/util/base64UrlEncode.js | 4 + server/media/util/createHmacSha256.js | 7 ++ server/routes/intergrationRoutes.js | 8 ++ server/routes/jobRoutes.js | 1 - 14 files changed, 152 insertions(+), 81 deletions(-) create mode 100644 server/integrations/VSSTA/vsstaIntegration.js create mode 100644 server/integrations/VSSTA/vsstaMiddleware.js create mode 100644 server/media/util/base64UrlEncode.js create mode 100644 server/media/util/createHmacSha256.js create mode 100644 server/routes/intergrationRoutes.js diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.download.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.download.component.jsx index 6c08936dc..8644115fd 100644 --- a/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.download.component.jsx +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.download.component.jsx @@ -1,12 +1,10 @@ import { Button, Space } from "antd"; import axios from "axios"; -import React, { useState } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { logImEXEvent } from "../../firebase/firebase.utils"; import cleanAxios from "../../utils/CleanAxios"; import formatBytes from "../../utils/formatbytes"; -//import yauzl from "yauzl"; - import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectBodyshop } from "../../redux/user/user.selectors"; @@ -28,7 +26,7 @@ const mapDispatchToProps = (dispatch) => ({ export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyDownloadButton); -export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, identifier }) { +export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, identifier, jobId }) { const { t } = useTranslation(); const [download, setDownload] = useState(null); const [loading, setLoading] = useState(false); @@ -46,6 +44,7 @@ export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, i }; }); } + function standardMediaDownload(bufferData) { const a = document.createElement("a"); const url = window.URL.createObjectURL(new Blob([bufferData])); @@ -53,13 +52,14 @@ export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, i a.download = `${identifier || "documents"}.zip`; a.click(); } + const handleDownload = async () => { logImEXEvent("jobs_documents_download"); setLoading(true); const zipUrl = await axios({ url: "/media/imgproxy/download", method: "POST", - data: { documentids: imagesToDownload.map((_) => _.id) } + data: { jobId, documentids: imagesToDownload.map((_) => _.id) } }); const theDownloadedZip = await cleanAxios({ diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx index a07ed0bf1..f99485dc8 100644 --- a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx @@ -75,7 +75,7 @@ function JobsDocumentsImgproxyComponent({ - + { app.use("/cdk", require("./server/routes/cdkRoutes")); app.use("/csi", require("./server/routes/csiRoutes")); app.use("/payroll", require("./server/routes/payrollRoutes")); + app.use("/integrations", require("./server/routes/intergrationRoutes")); // Default route for forbidden access app.get("/", (req, res) => { diff --git a/server/accounting/pbs/pbs-ap-allocations.js b/server/accounting/pbs/pbs-ap-allocations.js index 9574b166d..62bd84270 100644 --- a/server/accounting/pbs/pbs-ap-allocations.js +++ b/server/accounting/pbs/pbs-ap-allocations.js @@ -217,7 +217,7 @@ exports.PbsExportAp = async function (socket, { billids, txEnvelope }) { socket.emit("ap-export-success", billid); } else { - CdkBase.createLogEvent(socket, "ERROR", `Export was not succesful.`); + CdkBase.createLogEvent(socket, "ERROR", `Export was not successful.`); socket.emit("ap-export-failure", { billid, error: AccountPostingChange.Message diff --git a/server/accounting/pbs/pbs-job-export.js b/server/accounting/pbs/pbs-job-export.js index c38560293..e3dc20dcf 100644 --- a/server/accounting/pbs/pbs-job-export.js +++ b/server/accounting/pbs/pbs-job-export.js @@ -105,14 +105,14 @@ exports.PbsSelectedCustomer = async function PbsSelectedCustomer(socket, selecte socket.emit("export-success", socket.JobData.id); } else { - CdkBase.createLogEvent(socket, "ERROR", `Export was not succesful.`); + CdkBase.createLogEvent(socket, "ERROR", `Export was not successful.`); } } catch (error) { CdkBase.createLogEvent(socket, "ERROR", `Error encountered in CdkSelectedCustomer. ${error}`); await InsertFailedExportLog(socket, error); } }; - +// Was Successful async function CheckForErrors(socket, response) { if (response.WasSuccessful === undefined || response.WasSuccessful === true) { CdkBase.createLogEvent(socket, "DEBUG", `Successful response from DMS. ${response.Message || ""}`); diff --git a/server/graphql-client/graphql-client.js b/server/graphql-client/graphql-client.js index 069386b73..79d86315b 100644 --- a/server/graphql-client/graphql-client.js +++ b/server/graphql-client/graphql-client.js @@ -1,17 +1,19 @@ const GraphQLClient = require("graphql-request").GraphQLClient; -const path = require("path"); -require("dotenv").config({ - path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) -}); + //New bug introduced with Graphql Request. // https://github.com/prisma-labs/graphql-request/issues/206 // const { Headers } = require("cross-fetch"); // global.Headers = global.Headers || Headers; -exports.client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, { +const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, { headers: { "x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET } }); -exports.unauthclient = new GraphQLClient(process.env.GRAPHQL_ENDPOINT); +const unauthorizedClient = new GraphQLClient(process.env.GRAPHQL_ENDPOINT); + +module.exports = { + client, + unauthorizedClient +}; diff --git a/server/integrations/VSSTA/vsstaIntegration.js b/server/integrations/VSSTA/vsstaIntegration.js new file mode 100644 index 000000000..30f7531a9 --- /dev/null +++ b/server/integrations/VSSTA/vsstaIntegration.js @@ -0,0 +1,36 @@ +const client = require("../../graphql-client/graphql-client").client; + +/** + * VSSTA Integration + * @param req + * @param res + * @returns {Promise} + */ +const vsstaIntegration = async (req, res) => { + const { logger } = req; + + // Examplwe req.body + //{ + // "shop_id":"test", + // "“ro_nbr“":"71475", + // "vin_nbr":"12345678912345678", + // "pdf_download_link":"https://portal-staging.vssta.com/invoice_data/1500564", + // "“company_api_key“":"xxxxx", + // "scan_type":"PRE", + // "scan_fee":"119.00", + // "scanner_number":"1234", + // "scan_time":"2022-08-23 17:53:50", + // "technician":"Frank Jones", + // "year":"2021", + // "make":"TOYOTA", + // "model":"Tacoma SR5 grade" + // + // } + // 1 - We would want to get the Job by searching the ro_nbr and shop_id (The assumption) + + // 2 - We want to download the file provided from the pdf_download_link and associate (upload) it + // to S3 bucket for media, and insert a document record in the database, the file is base64 encoded (pdf), we will want to unencode it when storing it as a pdf + // We might not have to un-encode it, ultimately we want to send the base64 and the end is a pdf file the user can view from the documents section. +}; + +module.exports = vsstaIntegration; diff --git a/server/integrations/VSSTA/vsstaMiddleware.js b/server/integrations/VSSTA/vsstaMiddleware.js new file mode 100644 index 000000000..800f9bfa2 --- /dev/null +++ b/server/integrations/VSSTA/vsstaMiddleware.js @@ -0,0 +1,5 @@ +const vsstaMiddleware = (req, res, next) => { + next(); +}; + +module.exports = vsstaMiddleware; diff --git a/server/media/imgproxy-media.js b/server/media/imgproxy-media.js index fdb313984..d26b572ce 100644 --- a/server/media/imgproxy-media.js +++ b/server/media/imgproxy-media.js @@ -1,8 +1,12 @@ const path = require("path"); -require("dotenv").config({ - path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) -}); const logger = require("../utils/logger"); +const { Upload } = require("@aws-sdk/lib-storage"); +const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); +const { InstanceRegion } = require("../utils/instanceMgr"); +const archiver = require("archiver"); +const stream = require("node:stream"); +const base64UrlEncode = require("./util/base64UrlEncode"); +const createHmacSha256 = require("./util/createHmacSha256"); const { S3Client, PutObjectCommand, @@ -10,35 +14,31 @@ const { CopyObjectCommand, DeleteObjectCommand } = require("@aws-sdk/client-s3"); -const { Upload } = require("@aws-sdk/lib-storage"); - -const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); -const crypto = require("crypto"); -const { InstanceRegion } = require("../utils/instanceMgr"); const { GET_DOCUMENTS_BY_JOB, QUERY_TEMPORARY_DOCS, GET_DOCUMENTS_BY_IDS, DELETE_MEDIA_DOCUMENTS } = require("../graphql-client/queries"); -const archiver = require("archiver"); -const stream = require("node:stream"); const imgproxyBaseUrl = process.env.IMGPROXY_BASE_URL; // `https://u4gzpp5wm437dnm75qa42tvza40fguqr.lambda-url.ca-central-1.on.aws` //Direct Lambda function access to bypass CDN. -const imgproxyKey = process.env.IMGPROXY_KEY; const imgproxySalt = process.env.IMGPROXY_SALT; const imgproxyDestinationBucket = process.env.IMGPROXY_DESTINATION_BUCKET; //Generate a signed upload link for the S3 bucket. //All uploads must be going to the same shop and jobid. -exports.generateSignedUploadUrls = async (req, res) => { +const generateSignedUploadUrls = async (req, res) => { const { filenames, bodyshopid, jobid } = req.body; try { - logger.log("imgproxy-upload-start", "DEBUG", req.user?.email, jobid, { filenames, bodyshopid, jobid }); + logger.log("imgproxy-upload-start", "DEBUG", req.user?.email, jobid, { + filenames, + bodyshopid, + jobid + }); const signedUrls = []; for (const filename of filenames) { - const key = filename; + const key = filename; const client = new S3Client({ region: InstanceRegion() }); const command = new PutObjectCommand({ Bucket: imgproxyDestinationBucket, @@ -67,7 +67,7 @@ exports.generateSignedUploadUrls = async (req, res) => { } }; -exports.getThumbnailUrls = async (req, res) => { +const getThumbnailUrls = async (req, res) => { const { jobid, billid } = req.body; try { @@ -86,10 +86,11 @@ exports.getThumbnailUrls = async (req, res) => { for (const document of data.documents) { //Format to follow: - /////< base 64 URL encoded to image path> - + /////< base 64 URL encoded to image path> //When working with documents from Cloudinary, the URL does not include the extension. + let key; + if (/\.[^/.]+$/.test(document.key)) { key = document.key; } else { @@ -98,12 +99,12 @@ exports.getThumbnailUrls = async (req, res) => { // Build the S3 path to the object. const fullS3Path = `s3://${imgproxyDestinationBucket}/${key}`; const base64UrlEncodedKeyString = base64UrlEncode(fullS3Path); + //Thumbnail Generation Block const thumbProxyPath = `${thumbResizeParams}/${base64UrlEncodedKeyString}`; const thumbHmacSalt = createHmacSha256(`${imgproxySalt}/${thumbProxyPath}`); //Full Size URL block - const fullSizeProxyPath = `${base64UrlEncodedKeyString}`; const fullSizeHmacSalt = createHmacSha256(`${imgproxySalt}/${fullSizeProxyPath}`); @@ -114,8 +115,8 @@ exports.getThumbnailUrls = async (req, res) => { Bucket: imgproxyDestinationBucket, Key: key }); - const presignedGetUrl = await getSignedUrl(s3client, command, { expiresIn: 360 }); - s3Props.presignedGetUrl = presignedGetUrl; + + s3Props.presignedGetUrl = await getSignedUrl(s3client, command, { expiresIn: 360 }); const originalProxyPath = `raw:1/${base64UrlEncodedKeyString}`; const originalHmacSalt = createHmacSha256(`${imgproxySalt}/${originalProxyPath}`); @@ -146,40 +147,46 @@ exports.getThumbnailUrls = async (req, res) => { } }; -exports.getBillFiles = async (req, res) => { - //Givena bill ID, get the documents associated to it. -}; - -exports.downloadFiles = async (req, res) => { +const downloadFiles = async (req, res) => { //Given a series of document IDs or keys, generate a file (or a link) to download all images in bulk - const { jobid, billid, documentids } = req.body; + const { jobId, billid, documentids } = req.body; + try { - logger.log("imgproxy-download", "DEBUG", req.user?.email, jobid, { billid, jobid, documentids }); + logger.log("imgproxy-download", "DEBUG", req.user?.email, jobId, { billid, jobId, documentids }); //Delayed as the key structure may change slightly from what it is currently and will require evaluating mobile components. const client = req.userGraphQLClient; + //Query for the keys of the document IDs const data = await client.request(GET_DOCUMENTS_BY_IDS, { documentIds: documentids }); - //Using the Keys, get all of the S3 links, zip them, and send back to the client. + + //Using the Keys, get all the S3 links, zip them, and send back to the client. const s3client = new S3Client({ region: InstanceRegion() }); const archiveStream = archiver("zip"); + archiveStream.on("error", (error) => { console.error("Archival encountered an error:", error); throw new Error(error); }); + const passthrough = new stream.PassThrough(); archiveStream.pipe(passthrough); + for (const key of data.documents.map((d) => d.key)) { - const response = await s3client.send(new GetObjectCommand({ Bucket: imgproxyDestinationBucket, Key: key })); - // :: `response.Body` is a Buffer - console.log(path.basename(key)); + const response = await s3client.send( + new GetObjectCommand({ + Bucket: imgproxyDestinationBucket, + Key: key + }) + ); + archiveStream.append(response.Body, { name: path.basename(key) }); } - archiveStream.finalize(); + await archiveStream.finalize(); - const archiveKey = `archives/${jobid}/archive-${new Date().toISOString()}.zip`; + const archiveKey = `archives/${jobId || "na"}/archive-${new Date().toISOString()}.zip`; const parallelUploads3 = new Upload({ client: s3client, @@ -192,7 +199,7 @@ exports.downloadFiles = async (req, res) => { console.log(progress); }); - const uploadResult = await parallelUploads3.done(); + await parallelUploads3.done(); //Generate the presigned URL to download it. const presignedUrl = await getSignedUrl( s3client, @@ -203,8 +210,8 @@ exports.downloadFiles = async (req, res) => { res.json({ success: true, url: presignedUrl }); //Iterate over them, build the link based on the media type, and return the array. } catch (error) { - logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobid, { - jobid, + logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobId, { + jobId, billid, message: error.message, stack: error.stack @@ -213,7 +220,7 @@ exports.downloadFiles = async (req, res) => { } }; -exports.deleteFiles = async (req, res) => { +const deleteFiles = async (req, res) => { //Mark a file for deletion in s3. Lifecycle deletion will actually delete the copy in the future. //Mark as deleted from the documents section of the database. const { ids } = req.body; @@ -232,7 +239,7 @@ exports.deleteFiles = async (req, res) => { (async () => { try { // Delete the original object - const deleteResult = await s3client.send( + await s3client.send( new DeleteObjectCommand({ Bucket: imgproxyDestinationBucket, Key: document.key @@ -250,7 +257,7 @@ exports.deleteFiles = async (req, res) => { const result = await Promise.all(deleteTransactions); const errors = result.filter((d) => d.error); - //Delete only the succesful deletes. + //Delete only the successful deletes. const deleteMutationResult = await client.request(DELETE_MEDIA_DOCUMENTS, { ids: result.filter((t) => !t.error).map((d) => d.id) }); @@ -266,7 +273,7 @@ exports.deleteFiles = async (req, res) => { } }; -exports.moveFiles = async (req, res) => { +const moveFiles = async (req, res) => { const { documents, tojobid } = req.body; try { logger.log("imgproxy-move-files", "DEBUG", req.user.email, null, { documents, tojobid }); @@ -278,7 +285,7 @@ exports.moveFiles = async (req, res) => { (async () => { try { // Copy the object to the new key - const copyresult = await s3client.send( + await s3client.send( new CopyObjectCommand({ Bucket: imgproxyDestinationBucket, CopySource: `${imgproxyDestinationBucket}/${document.from}`, @@ -288,7 +295,7 @@ exports.moveFiles = async (req, res) => { ); // Delete the original object - const deleteResult = await s3client.send( + await s3client.send( new DeleteObjectCommand({ Bucket: imgproxyDestinationBucket, Key: document.from @@ -297,7 +304,12 @@ exports.moveFiles = async (req, res) => { return document; } catch (error) { - return { id: document.id, from: document.from, error: error, bucket: imgproxyDestinationBucket }; + return { + id: document.id, + from: document.from, + error: error, + bucket: imgproxyDestinationBucket + }; } })() ); @@ -307,6 +319,7 @@ exports.moveFiles = async (req, res) => { const errors = result.filter((d) => d.error); let mutations = ""; + result .filter((d) => !d.error) .forEach((d, idx) => { @@ -327,7 +340,7 @@ exports.moveFiles = async (req, res) => { }`); res.json({ errors, mutationResult }); } else { - res.json({ errors: "No images were succesfully moved on remote server. " }); + res.json({ errors: "No images were successfully moved on remote server. " }); } } catch (error) { logger.log("imgproxy-move-files-error", "ERROR", req.user.email, null, { @@ -340,9 +353,10 @@ exports.moveFiles = async (req, res) => { } }; -function base64UrlEncode(str) { - return Buffer.from(str).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} -function createHmacSha256(data) { - return crypto.createHmac("sha256", imgproxyKey).update(data).digest("base64url"); -} +module.exports = { + generateSignedUploadUrls, + getThumbnailUrls, + downloadFiles, + deleteFiles, + moveFiles +}; diff --git a/server/media/media.js b/server/media/media.js index 06b1c9bb8..af9628c8a 100644 --- a/server/media/media.js +++ b/server/media/media.js @@ -1,14 +1,9 @@ -const path = require("path"); const _ = require("lodash"); const logger = require("../utils/logger"); const client = require("../graphql-client/graphql-client").client; const queries = require("../graphql-client/queries"); -require("dotenv").config({ - path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) -}); - -var cloudinary = require("cloudinary").v2; +const cloudinary = require("cloudinary").v2; cloudinary.config(process.env.CLOUDINARY_URL); const createSignedUploadURL = (req, res) => { @@ -16,8 +11,6 @@ const createSignedUploadURL = (req, res) => { res.send(cloudinary.utils.api_sign_request(req.body, process.env.CLOUDINARY_API_SECRET)); }; -exports.createSignedUploadURL = createSignedUploadURL; - const downloadFiles = (req, res) => { const { ids } = req.body; logger.log("media-bulk-download", "DEBUG", req.user.email, ids, null); @@ -28,7 +21,6 @@ const downloadFiles = (req, res) => { }); res.send(url); }; -exports.downloadFiles = downloadFiles; const deleteFiles = async (req, res) => { const { ids } = req.body; @@ -91,8 +83,6 @@ const deleteFiles = async (req, res) => { } }; -exports.deleteFiles = deleteFiles; - const renameKeys = async (req, res) => { const { documents, tojobid } = req.body; logger.log("media-bulk-rename", "DEBUG", req.user.email, null, documents); @@ -102,13 +92,12 @@ const renameKeys = async (req, res) => { proms.push( (async () => { try { - const res = { + return { id: d.id, ...(await cloudinary.uploader.rename(d.from, d.to, { resource_type: DetermineFileType(d.type) })) }; - return res; } catch (error) { return { id: d.id, from: d.from, error: error }; } @@ -148,10 +137,9 @@ const renameKeys = async (req, res) => { }`); res.json({ errors, mutationResult }); } else { - res.json({ errors: "No images were succesfully moved on remote server. " }); + res.json({ errors: "No images were successfully moved on remote server. " }); } }; -exports.renameKeys = renameKeys; //Also needs to be updated in upload utility and mobile app. function DetermineFileType(filetype) { @@ -163,3 +151,10 @@ function DetermineFileType(filetype) { return "auto"; } + +module.exports = { + createSignedUploadURL, + downloadFiles, + deleteFiles, + renameKeys +}; diff --git a/server/media/util/base64UrlEncode.js b/server/media/util/base64UrlEncode.js new file mode 100644 index 000000000..4094148b3 --- /dev/null +++ b/server/media/util/base64UrlEncode.js @@ -0,0 +1,4 @@ +const base64UrlEncode = (str) => + Buffer.from(str).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + +module.exports = base64UrlEncode; diff --git a/server/media/util/createHmacSha256.js b/server/media/util/createHmacSha256.js new file mode 100644 index 000000000..05b7d52a3 --- /dev/null +++ b/server/media/util/createHmacSha256.js @@ -0,0 +1,7 @@ +const crypto = require("crypto"); + +const imgproxyKey = process.env.IMGPROXY_KEY; + +const createHmacSha256 = (data) => crypto.createHmac("sha256", imgproxyKey).update(data).digest("base64url"); + +module.exports = createHmacSha256; diff --git a/server/routes/intergrationRoutes.js b/server/routes/intergrationRoutes.js new file mode 100644 index 000000000..9d3fc20f4 --- /dev/null +++ b/server/routes/intergrationRoutes.js @@ -0,0 +1,8 @@ +const express = require("express"); +const vsstaIntegration = require("../integrations/VSSTA/vsstaIntegration"); +const vsstaMiddleware = require("../integrations/VSSTA/vsstaMiddleware"); +const router = express.Router(); + +router.post("/vssta", vsstaMiddleware, vsstaIntegration); + +module.exports = router; diff --git a/server/routes/jobRoutes.js b/server/routes/jobRoutes.js index aab3e8823..e7c747907 100644 --- a/server/routes/jobRoutes.js +++ b/server/routes/jobRoutes.js @@ -1,6 +1,5 @@ const express = require("express"); const router = express.Router(); -const job = require("../job/job"); const ppc = require("../ccc/partspricechange"); const { partsScan } = require("../parts-scan/parts-scan"); const eventAuthorizationMiddleware = require("../middleware/eventAuthorizationMIddleware"); From 5adf591670cd99f444bd584e31cad84e8822cb2d Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 10 Apr 2025 09:27:49 -0400 Subject: [PATCH 002/195] feature/IO-2282-VSSTA-Integration: - Clean up imgproxy-media.js --- server/media/imgproxy-media.js | 43 ++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/server/media/imgproxy-media.js b/server/media/imgproxy-media.js index d26b572ce..6bee7a6bc 100644 --- a/server/media/imgproxy-media.js +++ b/server/media/imgproxy-media.js @@ -50,17 +50,19 @@ const generateSignedUploadUrls = async (req, res) => { } logger.log("imgproxy-upload-success", "DEBUG", req.user?.email, jobid, { signedUrls }); - res.json({ + + return res.json({ success: true, signedUrls }); } catch (error) { - res.status(400).json({ - success: false, + logger.log("imgproxy-upload-error", "ERROR", req.user?.email, jobid, { message: error.message, stack: error.stack }); - logger.log("imgproxy-upload-error", "ERROR", req.user?.email, jobid, { + + return res.status(400).json({ + success: false, message: error.message, stack: error.stack }); @@ -134,7 +136,7 @@ const getThumbnailUrls = async (req, res) => { }); } - res.json(proxiedUrls); + return res.json(proxiedUrls); //Iterate over them, build the link based on the media type, and return the array. } catch (error) { logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobid, { @@ -143,7 +145,8 @@ const getThumbnailUrls = async (req, res) => { message: error.message, stack: error.stack }); - res.status(400).json({ message: error.message, stack: error.stack }); + + return res.status(400).json({ message: error.message, stack: error.stack }); } }; @@ -169,9 +172,9 @@ const downloadFiles = async (req, res) => { throw new Error(error); }); - const passthrough = new stream.PassThrough(); + const passThrough = new stream.PassThrough(); - archiveStream.pipe(passthrough); + archiveStream.pipe(passThrough); for (const key of data.documents.map((d) => d.key)) { const response = await s3client.send( @@ -192,7 +195,7 @@ const downloadFiles = async (req, res) => { client: s3client, queueSize: 4, // optional concurrency configuration leavePartsOnError: false, // optional manually handle dropped parts - params: { Bucket: imgproxyDestinationBucket, Key: archiveKey, Body: passthrough } + params: { Bucket: imgproxyDestinationBucket, Key: archiveKey, Body: passThrough } }); parallelUploads3.on("httpUploadProgress", (progress) => { @@ -200,6 +203,7 @@ const downloadFiles = async (req, res) => { }); await parallelUploads3.done(); + //Generate the presigned URL to download it. const presignedUrl = await getSignedUrl( s3client, @@ -207,7 +211,7 @@ const downloadFiles = async (req, res) => { { expiresIn: 360 } ); - res.json({ success: true, url: presignedUrl }); + return res.json({ success: true, url: presignedUrl }); //Iterate over them, build the link based on the media type, and return the array. } catch (error) { logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobId, { @@ -216,7 +220,8 @@ const downloadFiles = async (req, res) => { message: error.message, stack: error.stack }); - res.status(400).json({ message: error.message, stack: error.stack }); + + return res.status(400).json({ message: error.message, stack: error.stack }); } }; @@ -262,14 +267,15 @@ const deleteFiles = async (req, res) => { ids: result.filter((t) => !t.error).map((d) => d.id) }); - res.json({ errors, deleteMutationResult }); + return res.json({ errors, deleteMutationResult }); } catch (error) { logger.log("imgproxy-delete-files-error", "ERROR", req.user.email, null, { ids, message: error.message, stack: error.stack }); - res.status(400).json({ message: error.message, stack: error.stack }); + + return res.status(400).json({ message: error.message, stack: error.stack }); } }; @@ -334,14 +340,16 @@ const moveFiles = async (req, res) => { }); const client = req.userGraphQLClient; + if (mutations !== "") { const mutationResult = await client.request(`mutation { ${mutations} }`); - res.json({ errors, mutationResult }); - } else { - res.json({ errors: "No images were successfully moved on remote server. " }); + + return res.json({ errors, mutationResult }); } + + return res.json({ errors: "No images were successfully moved on remote server. " }); } catch (error) { logger.log("imgproxy-move-files-error", "ERROR", req.user.email, null, { documents, @@ -349,7 +357,8 @@ const moveFiles = async (req, res) => { message: error.message, stack: error.stack }); - res.status(400).json({ message: error.message, stack: error.stack }); + + return res.status(400).json({ message: error.message, stack: error.stack }); } }; From e8b9fcbc6e5a99d7b1d2468a3340a0f4d7de0205 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 10 Apr 2025 09:37:31 -0400 Subject: [PATCH 003/195] feature/IO-2282-VSSTA-Integration: - Clean up imgproxy-media.js --- server/media/imgproxy-media.js | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/server/media/imgproxy-media.js b/server/media/imgproxy-media.js index 6bee7a6bc..5790ecfb7 100644 --- a/server/media/imgproxy-media.js +++ b/server/media/imgproxy-media.js @@ -25,8 +25,13 @@ const imgproxyBaseUrl = process.env.IMGPROXY_BASE_URL; // `https://u4gzpp5wm437d const imgproxySalt = process.env.IMGPROXY_SALT; const imgproxyDestinationBucket = process.env.IMGPROXY_DESTINATION_BUCKET; -//Generate a signed upload link for the S3 bucket. -//All uploads must be going to the same shop and jobid. +/** + * Generate a Signed URL Link for the s3 bucket. + * All Uploads must be going to the same Shop and JobId + * @param req + * @param res + * @returns {Promise<*>} + */ const generateSignedUploadUrls = async (req, res) => { const { filenames, bodyshopid, jobid } = req.body; try { @@ -69,6 +74,12 @@ const generateSignedUploadUrls = async (req, res) => { } }; +/** + * Get Thumbnail URLS + * @param req + * @param res + * @returns {Promise<*>} + */ const getThumbnailUrls = async (req, res) => { const { jobid, billid } = req.body; @@ -150,6 +161,12 @@ const getThumbnailUrls = async (req, res) => { } }; +/** + * Download Files + * @param req + * @param res + * @returns {Promise<*>} + */ const downloadFiles = async (req, res) => { //Given a series of document IDs or keys, generate a file (or a link) to download all images in bulk const { jobId, billid, documentids } = req.body; @@ -225,6 +242,12 @@ const downloadFiles = async (req, res) => { } }; +/** + * Delete Files + * @param req + * @param res + * @returns {Promise<*>} + */ const deleteFiles = async (req, res) => { //Mark a file for deletion in s3. Lifecycle deletion will actually delete the copy in the future. //Mark as deleted from the documents section of the database. @@ -279,6 +302,12 @@ const deleteFiles = async (req, res) => { } }; +/** + * Move Files + * @param req + * @param res + * @returns {Promise<*>} + */ const moveFiles = async (req, res) => { const { documents, tojobid } = req.body; try { From 22b011139d706a024b1f25fbf9bf3ff27df6003f Mon Sep 17 00:00:00 2001 From: Patrick Fic Date: Mon, 14 Apr 2025 15:29:45 -0700 Subject: [PATCH 004/195] IO-3066 Call partner refresh on shop change. --- .../src/components/profile-shops/profile-shops.container.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/src/components/profile-shops/profile-shops.container.jsx b/client/src/components/profile-shops/profile-shops.container.jsx index 007c0ddc7..fae2cf6b6 100644 --- a/client/src/components/profile-shops/profile-shops.container.jsx +++ b/client/src/components/profile-shops/profile-shops.container.jsx @@ -54,6 +54,9 @@ export function ProfileShopsContainer({ bodyshop, currentUser }) { //Force window refresh. + //Ping the new partner to refresh. + axios.post("http://localhost:1337/refresh"); + window.location.reload(); }; From b5cb5209442076860821b96f8531bf12282cd62b Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Mon, 14 Apr 2025 17:07:57 -0700 Subject: [PATCH 005/195] IO-3187 Admin Enhancements Signed-off-by: Allan Carr --- _reference/localEmailViewer/package-lock.json | 764 +++-- _reference/localEmailViewer/package.json | 4 +- ...etail-header-actions.toggle-production.jsx | 13 +- client/src/graphql/jobs.queries.js | 14 + server/email/generateTemplate.js | 130 +- server/email/html.js | 2765 +---------------- server/email/sendemail.js | 137 +- server/email/tasksEmails.js | 11 +- server/firebase/firebase-handler.js | 115 +- .../lib/sendPaymentNotificationEmail.js | 7 +- server/notifications/queues/emailQueue.js | 19 +- server/routes/adminRoutes.js | 4 +- 12 files changed, 863 insertions(+), 3120 deletions(-) diff --git a/_reference/localEmailViewer/package-lock.json b/_reference/localEmailViewer/package-lock.json index d8d53da2e..ae14abb19 100644 --- a/_reference/localEmailViewer/package-lock.json +++ b/_reference/localEmailViewer/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "express": "^4.21.1", - "mailparser": "^3.7.1", + "express": "^5.1.0", + "mailparser": "^3.7.2", "node-fetch": "^3.3.2" } }, @@ -28,46 +28,36 @@ } }, "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { "node": ">= 0.6" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=18" } }, "node_modules/bytes": { @@ -79,17 +69,27 @@ "node": ">= 0.8" } }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -99,9 +99,9 @@ } }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" @@ -129,10 +129,13 @@ } }, "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "license": "MIT" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", @@ -144,12 +147,20 @@ } }, "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "license": "MIT", "dependencies": { - "ms": "2.0.0" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/deepmerge": { @@ -161,23 +172,6 @@ "node": ">=0.10.0" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -187,16 +181,6 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -252,6 +236,20 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -268,9 +266,9 @@ } }, "node_modules/encoding-japanese": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.1.0.tgz", - "integrity": "sha512-58XySVxUgVlBikBTbQ8WdDxBDHIdXucB16LO5PBHR8t75D54wQrNo4cg+58+R1CtJfKnsVsvt9XlteRaR8xw1w==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", "license": "MIT", "engines": { "node": ">=8.10.0" @@ -289,13 +287,10 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, "engines": { "node": ">= 0.4" } @@ -309,6 +304,18 @@ "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -325,45 +332,45 @@ } }, "node_modules/express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { - "node": ">= 0.10.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/fetch-blob": { @@ -390,18 +397,17 @@ } }, "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" }, "engines": { "node": ">= 0.8" @@ -429,12 +435,12 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/function-bind": { @@ -447,16 +453,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -465,34 +476,23 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gopd": { + "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { - "get-intrinsic": "^1.1.3" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">= 0.4" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -502,9 +502,9 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -586,12 +586,12 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" @@ -612,6 +612,12 @@ "node": ">= 0.10" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/leac": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", @@ -628,33 +634,21 @@ "license": "MIT" }, "node_modules/libmime": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.5.tgz", - "integrity": "sha512-nSlR1yRZ43L3cZCiWEw7ali3jY29Hz9CQQ96Oy+sSspYnIP5N54ucOPHqooBsXzwrX1pwn13VUE05q4WmzfaLg==", + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.6.tgz", + "integrity": "sha512-j9mBC7eiqi6fgBPAGvKCXJKJSIASanYF4EeA4iBzSG0HxQxmXnR3KbyWqTn4CwsKSebqCv2f5XZfAO6sKzgvwA==", "license": "MIT", "dependencies": { - "encoding-japanese": "2.1.0", + "encoding-japanese": "2.2.0", "iconv-lite": "0.6.3", "libbase64": "1.3.0", - "libqp": "2.1.0" - } - }, - "node_modules/libmime/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" + "libqp": "2.1.1" } }, "node_modules/libqp": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.0.tgz", - "integrity": "sha512-O6O6/fsG5jiUVbvdgT7YX3xY3uIadR6wEZ7+vy9u7PKHAlSEB6blvC1o5pHBjgsi95Uo0aiBBdkyFecj6jtb7A==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", "license": "MIT" }, "node_modules/linkify-it": { @@ -667,161 +661,95 @@ } }, "node_modules/mailparser": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.1.tgz", - "integrity": "sha512-RCnBhy5q8XtB3mXzxcAfT1huNqN93HTYYyL6XawlIKycfxM/rXPg9tXoZ7D46+SgCS1zxKzw+BayDQSvncSTTw==", + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.2.tgz", + "integrity": "sha512-iI0p2TCcIodR1qGiRoDBBwboSSff50vQAWytM5JRggLfABa4hHYCf3YVujtuzV454xrOP352VsAPIzviqMTo4Q==", "license": "MIT", "dependencies": { - "encoding-japanese": "2.1.0", + "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", "iconv-lite": "0.6.3", - "libmime": "5.3.5", + "libmime": "5.3.6", "linkify-it": "5.0.0", - "mailsplit": "5.4.0", - "nodemailer": "6.9.13", + "mailsplit": "5.4.2", + "nodemailer": "6.9.16", "punycode.js": "2.3.1", - "tlds": "1.252.0" - } - }, - "node_modules/mailparser/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" + "tlds": "1.255.0" } }, "node_modules/mailsplit": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.0.tgz", - "integrity": "sha512-wnYxX5D5qymGIPYLwnp6h8n1+6P6vz/MJn5AzGjZ8pwICWssL+CCQjWBIToOVHASmATot4ktvlLo6CyLfOXWYA==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.2.tgz", + "integrity": "sha512-4cczG/3Iu3pyl8JgQ76dKkisurZTmxMrA4dj/e8d2jKYcFTZ7MxOzg1gTioTDMPuFXwTrVuN/gxhkrO7wLg7qA==", "license": "(MIT OR EUPL-1.1+)", "dependencies": { - "libbase64": "1.2.1", - "libmime": "5.2.0", - "libqp": "2.0.1" + "libbase64": "1.3.0", + "libmime": "5.3.6", + "libqp": "2.1.1" } }, - "node_modules/mailsplit/node_modules/encoding-japanese": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.0.0.tgz", - "integrity": "sha512-++P0RhebUC8MJAwJOsT93dT+5oc5oPImp1HubZpAuCZ5kTLnhuuBhKHj2jJeO/Gj93idPBWmIuQ9QWMe5rX3pQ==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { - "node": ">=8.10.0" + "node": ">= 0.4" } }, - "node_modules/mailsplit/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/mailsplit/node_modules/libbase64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.2.1.tgz", - "integrity": "sha512-l+nePcPbIG1fNlqMzrh68MLkX/gTxk/+vdvAb388Ssi7UuUN31MI44w4Yf33mM3Cm4xDfw48mdf3rkdHszLNew==", - "license": "MIT" - }, - "node_modules/mailsplit/node_modules/libmime": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.2.0.tgz", - "integrity": "sha512-X2U5Wx0YmK0rXFbk67ASMeqYIkZ6E5vY7pNWRKtnNzqjvdYYG8xtPDpCnuUEnPU9vlgNev+JoSrcaKSUaNvfsw==", - "license": "MIT", - "dependencies": { - "encoding-japanese": "2.0.0", - "iconv-lite": "0.6.3", - "libbase64": "1.2.1", - "libqp": "2.0.1" - } - }, - "node_modules/mailsplit/node_modules/libqp": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.0.1.tgz", - "integrity": "sha512-Ka0eC5LkF3IPNQHJmYBWljJsw0UvM6j+QdKRbWyCdTmYwvIDE6a7bCm0UkTAL/K+3KXK5qXT/ClcInU01OpdLg==", - "license": "MIT" - }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", + "engines": { + "node": ">=18" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.54.0" }, "engines": { "node": ">= 0.6" } }, "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -865,18 +793,18 @@ } }, "node_modules/nodemailer": { - "version": "6.9.13", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz", - "integrity": "sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA==", + "version": "6.9.16", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz", + "integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" } }, "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -897,6 +825,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseley": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", @@ -920,10 +857,13 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", - "license": "MIT" + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } }, "node_modules/peberminta": { "version": "0.9.0", @@ -957,12 +897,12 @@ } }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -981,20 +921,36 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", - "iconv-lite": "0.4.24", + "iconv-lite": "0.6.3", "unpipe": "1.0.0" }, "engines": { "node": ">= 0.8" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1034,74 +990,40 @@ } }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" } }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "node": ">= 18" } }, "node_modules/setprototypeof": { @@ -1111,15 +1033,69 @@ "license": "ISC" }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -1138,9 +1114,9 @@ } }, "node_modules/tlds": { - "version": "1.252.0", - "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.252.0.tgz", - "integrity": "sha512-GA16+8HXvqtfEnw/DTcwB0UU354QE1n3+wh08oFjr6Znl7ZLAeUgYzCcK+/CCrOyE0vnHR8/pu3XXG3vDijXpQ==", + "version": "1.255.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.255.0.tgz", + "integrity": "sha512-tcwMRIioTcF/FcxLev8MJWxCp+GUALRhFEqbDoZrnowmKSGqPrl5pqS+Sut2m8BgJ6S4FExCSSpGffZ0Tks6Aw==", "license": "MIT", "bin": { "tlds": "bin.js" @@ -1156,13 +1132,14 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { "node": ">= 0.6" @@ -1183,15 +1160,6 @@ "node": ">= 0.8" } }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1209,6 +1177,12 @@ "engines": { "node": ">= 8" } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" } } } diff --git a/_reference/localEmailViewer/package.json b/_reference/localEmailViewer/package.json index ad795b60f..74cef99ff 100644 --- a/_reference/localEmailViewer/package.json +++ b/_reference/localEmailViewer/package.json @@ -11,8 +11,8 @@ "license": "ISC", "description": "", "dependencies": { - "express": "^4.21.1", - "mailparser": "^3.7.1", + "express": "^5.1.0", + "mailparser": "^3.7.2", "node-fetch": "^3.3.2" } } diff --git a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.toggle-production.jsx b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.toggle-production.jsx index f0df106f4..3b6d8ec1a 100644 --- a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.toggle-production.jsx +++ b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.toggle-production.jsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; +import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { GET_JOB_BY_PK_QUICK_INTAKE, JOB_PRODUCTION_TOGGLE } from "../../graphql/jobs.queries"; import { insertAuditTrail } from "../../redux/application/application.actions"; import { selectJobReadOnly } from "../../redux/application/application.selectors"; @@ -12,7 +13,6 @@ import { selectBodyshop } from "../../redux/user/user.selectors"; import AuditTrailMapping from "../../utils/AuditTrailMappings"; import { DateTimeFormatterFunction } from "../../utils/DateFormatter"; import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component"; -import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx"; const mapStateToProps = createStructuredSelector({ @@ -46,7 +46,16 @@ export function JobsDetailHeaderActionsToggleProduction({ if (data?.jobs_by_pk) { form.setFieldsValue({ actual_in: data.jobs_by_pk.actual_in ? data.jobs_by_pk.actual_in : dayjs(), - scheduled_completion: data.jobs_by_pk.scheduled_completion, + scheduled_completion: data.jobs_by_pk.scheduled_completion + ? data.jobs_by_pk.scheduled_completion + : data.jobs_by_pk.labhrs && + data.jobs_by_pk.larhrs && + dayjs().businessDaysAdd( + (data.jobs_by_pk.labhrs.aggregate.sum.mod_lb_hrs || + 0 + data.jobs_by_pk.larhrs.aggregate.sum.mod_lb_hrs || + 0) / bodyshop.target_touchtime, + "day" + ), actual_completion: data.jobs_by_pk.actual_completion, scheduled_delivery: data.jobs_by_pk.scheduled_delivery, actual_delivery: data.jobs_by_pk.actual_delivery diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js index a9f16c111..b31c5c17a 100644 --- a/client/src/graphql/jobs.queries.js +++ b/client/src/graphql/jobs.queries.js @@ -2570,6 +2570,20 @@ export const GET_JOB_BY_PK_QUICK_INTAKE = gql` actual_completion scheduled_delivery actual_delivery + labhrs: joblines_aggregate(where: { _and: [{ mod_lbr_ty: { _neq: "LAR" } }, { removed: { _eq: false } }] }) { + aggregate { + sum { + mod_lb_hrs + } + } + } + larhrs: joblines_aggregate(where: { _and: [{ mod_lbr_ty: { _eq: "LAR" } }, { removed: { _eq: false } }] }) { + aggregate { + sum { + mod_lb_hrs + } + } + } } } `; diff --git a/server/email/generateTemplate.js b/server/email/generateTemplate.js index 2887b2fd2..6769958a9 100644 --- a/server/email/generateTemplate.js +++ b/server/email/generateTemplate.js @@ -1,5 +1,3 @@ -const moment = require("moment"); -const { default: RenderInstanceManager } = require("../utils/instanceMgr"); const { header, end, start } = require("./html"); // Required Strings @@ -7,19 +5,6 @@ const { header, end, start } = require("./html"); // - subHeader - The subheader of the email // - body - The body of the email -// Optional Strings (Have default values) -// - footer - The footer of the email -// - dateLine - The date line of the email - -const defaultFooter = () => { - return RenderInstanceManager({ - imex: "ImEX Online Collision Repair Management System", - rome: "Rome Technologies" - }); -}; - -const now = () => moment().format("MM/DD/YYYY @ hh:mm a"); - /** * Generate the email template * @param strings @@ -32,81 +17,48 @@ const generateEmailTemplate = (strings) => { header + start + ` - - - - - - -
- - - - - - - - - -
-
${strings.header}
-
-

${strings.subHeader}

-
-
+ + ${ + strings.header && + ` + + +
+ +
+
${strings.header}
+
+ ` + } + ${ + strings.subHeader && + ` + + +
+ +
+

${strings.subHeader}

+
+ ` + } - - - - - - - -
- - - - - - -
${strings.body}
-
- - - - - - - - - - -` + - end + ${ + strings.body && + ` + + + +
+ +
+ ${strings.body} +
+ + ` + } + ` + + end(strings.dateLine) ); }; diff --git a/server/email/html.js b/server/email/html.js index ec96c6ff9..79f8a9fa9 100644 --- a/server/email/html.js +++ b/server/email/html.js @@ -1,2611 +1,172 @@ -const header = ` +const InstanceManager = require("../utils/instanceMgr").default; + +const header = ` + + - - + `; +const start = ` + +
+ You have received a message from ${InstanceManager({imex: "ImEX Online", rome: "Rome Online"})}. Open to see the full contents of this email. + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  + ͏‌ ͏‌ ͏‌ ͏‌  +
+ + +
+
+ + +
 
`; -const start = `
 
`; -const end = `
 
`; + +function end(dateLine) { + return ` + + + + + + + + + +
 
+
+
+ + +`}; module.exports = { header, diff --git a/server/email/sendemail.js b/server/email/sendemail.js index 26d5c8560..e8e6fb689 100644 --- a/server/email/sendemail.js +++ b/server/email/sendemail.js @@ -40,7 +40,9 @@ const logEmail = async (req, email) => { to: req?.body?.to, cc: req?.body?.cc, subject: req?.body?.subject, - email + email, + errorMessage: error?.message, + errorStack: error?.stack // info, }); } @@ -68,6 +70,7 @@ const sendServerEmail = async ({ subject, text }) => { ] } }, + // eslint-disable-next-line no-unused-vars (err, info) => { logger.log("server-email-failure", err ? "error" : "debug", null, null, { message: err?.message, @@ -80,6 +83,107 @@ const sendServerEmail = async ({ subject, text }) => { } }; +const sendWelcomeEmail = async ({ to, resetLink, dateLine, features }) => { + try { + await mailer.sendMail({ + from: InstanceManager({ + imex: `ImEX Online `, + rome: `Rome Online ` + }), + to, + subject: InstanceManager({ + imex: "Welcome to the ImEX Online platform.", + rome: "Welcome to the Rome Online platform." + }), + html: generateEmailTemplate({ + header: InstanceManager({ + imex: "Welcome to the ImEX Online platform.", + rome: "Welcome to the Rome Online platform." + }), + subHeader: `Your ${InstanceManager({imex: features?.allAccess ? "ImEX Online": "ImEX Lite", rome: features?.allAccess ? "RO Manager" : "RO Basic"})} shop setup has been completed, and this email will include all the information you need to begin.`, + body: ` +

To finish setting up your account, visit this link and enter your desired password. Reset Password

+ + + + + +
+ +
+

To access your ${InstanceManager({imex: features.allAccess ? "ImEX Online": "ImEX Lite", rome: features.allAccess ? "RO Manager" : "RO Basic"})} shop, visit ${InstanceManager({imex: "imex.online", rome: "romeonline.io"})}. Your username is your email, and your password is what you previously set up. Contact support for additional logins.

+
+ ${InstanceManager({ + rome: ` + + +
+ + +
+

To push estimates over from your estimating system, you must download the Web-Est EMS Unzipper & Rome Online Partner (Computers using Windows only). Here are some steps to help you get started.

+
+ +
+ + +
+ + +
+

Once you successfully set up the partner, now it's time to do some initial in-product items: Please note, an estimate must be exported from the estimating platform to use tours.

+
+ +
+ + +
+ +
+

If you need any assistance with setting up the programs, or if you want a dedicated Q&A session with one of our customer success specialists, schedule by clicking this link - Rome Basic Training Booking

+
+ + +
+ +
+

If you have additional questions or need any support, feel free to use the RO Basic support chat (blue chat box located in the bottom right corner) or give us a call at (410) 357-6700. We are here to help make your experience seamless!

+
+ ` + })} + + +
+ +
+

In addition to the training tour, you can also book a live one-on-one demo to see exactly how our system can help streamline the repair process at your shop, schedule by clicking this link - ${InstanceManager({imex: "ImEX Lite", rome: "Rome Basic"})} Demo Booking

+
+ + +
+ +
+

Thanks,

+
+ + +
+
+

The ${InstanceManager({imex: "ImEX Online", rome: "Rome Online"})} Team

+ `, + dateLine + }) + }); + } catch (error) { + logger.log("server-email-failure", "error", null, null, { error }); + } +}; + const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachments }) => { try { mailer.sendMail( @@ -93,6 +197,7 @@ const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachmen ...(type === "text" ? { text } : { html }), attachments: attachments || null }, + // eslint-disable-next-line no-unused-vars (err, info) => { // (message, type, user, record, meta logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message, stack: err?.stack }); @@ -143,22 +248,20 @@ const sendEmail = async (req, res) => { to: req.body.to, cc: req.body.cc, subject: req.body.subject, - attachments: - [ - ...((req.body.attachments && - req.body.attachments.map((a) => { - return { - filename: a.filename, - path: a.path - }; - })) || - []), - ...downloadedMedia.map((a) => { + attachments: [ + ...(req.body.attachments && + req.body.attachments.map((a) => { return { - path: a + filename: a.filename, + path: a.path }; - }) - ] || null, + })), + ...downloadedMedia.map((a) => { + return { + path: a + }; + }) + ], html: isObject(req.body?.templateStrings) ? generateEmailTemplate(req.body.templateStrings) : req.body.html, ses: { // optional extra arguments for SendRawEmail @@ -273,6 +376,7 @@ ${body.bounce?.bouncedRecipients.map( )} ` }, + // eslint-disable-next-line no-unused-vars (err, info) => { logger.log("sns-error", err ? "error" : "debug", "api", null, { errorMessage: err?.message, @@ -294,5 +398,6 @@ module.exports = { sendEmail, sendServerEmail, sendTaskEmail, - emailBounce + emailBounce, + sendWelcomeEmail }; diff --git a/server/email/tasksEmails.js b/server/email/tasksEmails.js index b8c3c42a3..2f434b0bc 100644 --- a/server/email/tasksEmails.js +++ b/server/email/tasksEmails.js @@ -17,11 +17,13 @@ const { formatTaskPriority } = require("../notifications/stringHelpers"); const tasksEmailQueue = taskEmailQueue(); // Cleanup function for the Tasks Email Queue +// eslint-disable-next-line no-unused-vars const tasksEmailQueueCleanup = async () => { try { // Example async operation // console.log("Performing Tasks Email Reminder process cleanup..."); await new Promise((resolve) => tasksEmailQueue.destroy(() => resolve())); + // eslint-disable-next-line no-unused-vars } catch (err) { // console.error("Tasks Email Reminder process cleanup failed:", err); } @@ -254,10 +256,15 @@ const tasksRemindEmail = async (req, res) => { header: `${allTasks.length} Tasks require your attention`, subHeader: `Please click on the Tasks below to view the Task.`, dateLine, - body: `
    + body: ` + ` diff --git a/server/firebase/firebase-handler.js b/server/firebase/firebase-handler.js index c60a04fab..48c2baf33 100644 --- a/server/firebase/firebase-handler.js +++ b/server/firebase/firebase-handler.js @@ -5,9 +5,10 @@ require("dotenv").config({ const admin = require("firebase-admin"); const logger = require("../utils/logger"); -//const { sendProManagerWelcomeEmail } = require("../email/sendemail"); +const { sendWelcomeEmail } = require("../email/sendemail"); const client = require("../graphql-client/graphql-client").client; const serviceAccount = require(process.env.FIREBASE_ADMINSDK_JSON); +const moment = require("moment-timezone"); //const generateEmailTemplate = require("../email/generateTemplate"); admin.initializeApp({ @@ -201,6 +202,114 @@ const unsubscribe = async (req, res) => { } }; +const sendwelcome = (req, res) => { + const { authid, email } = req.body; + + // Fetch user from Firebase + admin + .auth() + .getUser(authid) + .then((userRecord) => { + if (!userRecord) { + return Promise.reject({ status: 404, message: "User not found in Firebase." }); + } + + // Fetch user data from the database using GraphQL + return client.request( + ` + query GET_USER_BY_EMAIL($email: String!) { + users(where: { email: { _eq: $email } }) { + email + validemail + associations { + id + shopid + bodyshop { + id + convenient_company + features + timezone + } + } + } + }`, + { email: email.toLowerCase() } + ); + }) + .then((dbUserResult) => { + const dbUser = dbUserResult?.users?.[0]; + if (!dbUser) { + return Promise.reject({ status: 404, message: "User not found in database." }); + } + // Validate email before proceeding + if (!dbUser.validemail) { + logger.log("admin-send-welcome-email-skip", "debug", req.user.email, null, { + message: "User email is not valid, skipping email.", + email + }); + return res.status(200).json({ message: "User email is not valid, email not sent." }); + } + + // Generate password reset link + return admin + .auth() + .generatePasswordResetLink(dbUser.email) + .then((resetLink) => ({ dbUser, resetLink })); + }) + .then(({ dbUser, resetLink }) => { + // Send welcome email (replace with your actual email-sending service) + return sendWelcomeEmail({ + to: dbUser.email, + resetLink, + dateLine: moment().tz(dbUser.associations?.[0]?.bodyshop?.timezone).format("MM/DD/YYYY @ hh:mm a"), + features: dbUser.associations?.[0]?.bodyshop?.features + }); + }) + .then(() => { + // Log success and return response + logger.log("admin-send-welcome-email", "debug", req.user.email, null, { + request: req.body, + ioadmin: true, + emailSentTo: email + }); + res.status(200).json({ message: "Welcome email sent successfully." }); + }) + .catch((error) => { + logger.log("admin-send-welcome-email-error", "ERROR", req.user.email, null, { error }); + + if (!res.headersSent) { + res.status(error.status || 500).json({ + message: error.message || "Error sending welcome email.", + error + }); + } + }); +}; + +const resetlink = (req, res) => { + const { authid, email } = req.body; + logger.log("admin-reset-link", "debug", req.user.email, null, { authid: authid, email: email }); + admin + .auth() + .getUser(authid) + .then((userRecord) => { + if (!userRecord) { + return Promise.reject({ status: 404, message: "User not found in Firebase." }); + } + return admin + .auth() + .generatePasswordResetLink(email) + .then((resetLink) => ({ userRecord, resetLink })); + }) + .then(({ resetLink }) => { + logger.log("admin-reset-link-success", "debug", req.user.email, null, { + request: req.body, + ioadmin: true, + }); + res.status(200).json({ message: "Reset link generated successfully.", resetLink }); + }); +}; + module.exports = { admin, createUser, @@ -208,7 +317,9 @@ module.exports = { getUser, sendNotification, subscribe, - unsubscribe + unsubscribe, + sendwelcome, + resetlink }; //Admin claims code. diff --git a/server/intellipay/lib/sendPaymentNotificationEmail.js b/server/intellipay/lib/sendPaymentNotificationEmail.js index 2f83d3a3b..c7b55d6d4 100644 --- a/server/intellipay/lib/sendPaymentNotificationEmail.js +++ b/server/intellipay/lib/sendPaymentNotificationEmail.js @@ -1,5 +1,6 @@ const { sendTaskEmail } = require("../../email/sendemail"); const generateEmailTemplate = require("../../email/generateTemplate"); +const { InstanceEndpoints } = require("../../utils/instanceMgr"); /** * @description Send notification email to the user @@ -22,11 +23,9 @@ const sendPaymentNotificationEmail = async (userEmail, jobs, partialPayments, lo body: jobs.jobs .map( (job) => - `Reference: ${job.ro_number || "N/A"} | ${ - job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim() - } | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}` + `

    Reference: ${job.ro_number || "N/A"} | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}

    ` ) - .join("
    ") + .join("") }) }); } catch (error) { diff --git a/server/notifications/queues/emailQueue.js b/server/notifications/queues/emailQueue.js index ff6702635..7d965cb69 100644 --- a/server/notifications/queues/emailQueue.js +++ b/server/notifications/queues/emailQueue.js @@ -133,11 +133,19 @@ const loadEmailQueue = async ({ pubClient, logger }) => { subHeader: `Dear ${firstName},`, dateLine: moment().tz(timezone).format("MM/DD/YYYY hh:mm a"), body: ` -

    There have been updates to job ${jobRoNumber || "N/A"} at ${bodyShopName}:


    -
      - ${messages.map((msg) => `
    • ${msg}
    • `).join("")} -


    -

    Please check the job for more details.

    +

    There have been updates to job ${jobRoNumber || "N/A"} at ${bodyShopName}:

    +
+ + +
+
    + ${messages.map((msg) => `
  • ${msg}
  • `).join("")} +
+
+ +
diff --git a/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx b/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx index dd49ffee0..f237b66ec 100644 --- a/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx +++ b/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx @@ -10,6 +10,7 @@ import { setModalContext } from "../../redux/modals/modals.actions"; import { selectBodyshop } from "../../redux/user/user.selectors"; import CurrencyFormatter from "../../utils/CurrencyFormatter"; import { DateTimeFormatter } from "../../utils/DateFormatter"; +import dayjs from "../../utils/day"; import PhoneNumberFormatter from "../../utils/PhoneFormatter"; import ChatOpenButton from "../chat-open-button/chat-open-button.component"; import DataLabel from "../data-label/data-label.component"; @@ -21,7 +22,6 @@ import ProductionListColumnComment from "../production-list-columns/production-l import ProductionListColumnProductionNote from "../production-list-columns/production-list-columns.productionnote.component"; import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component"; import "./jobs-detail-header.styles.scss"; -import dayjs from "../../utils/day"; const mapStateToProps = createStructuredSelector({ jobRO: selectJobReadOnly, @@ -149,6 +149,14 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) { )} + {job.hit_and_run && ( + + + + {t("jobs.fields.hit_and_run")} + + + )} diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js index b31c5c17a..5d5c33bbf 100644 --- a/client/src/graphql/jobs.queries.js +++ b/client/src/graphql/jobs.queries.js @@ -511,6 +511,7 @@ export const GET_JOB_BY_PK = gql` est_ph1 flat_rate_ats federal_tax_rate + hit_and_run id inproduction ins_addr1 diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 0a1ec198a..d546213b6 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -1764,6 +1764,7 @@ "flat_rate_ats": "Flat Rate ATS?", "federal_tax_payable": "Federal Tax Payable", "federal_tax_rate": "Federal Tax Rate", + "hit_and_run": "Hit and Run", "ins_addr1": "Insurance Co. Address", "ins_city": "Insurance Co. City", "ins_co_id": "Insurance Co. ID", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 820fd2973..5098a0d20 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -1764,6 +1764,7 @@ "flat_rate_ats": "", "federal_tax_payable": "Impuesto federal por pagar", "federal_tax_rate": "", + "hit_and_run": "", "ins_addr1": "Dirección de Insurance Co.", "ins_city": "Ciudad de seguros", "ins_co_id": "ID de la compañía de seguros", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index cd799fec9..6cafb9147 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -1765,6 +1765,7 @@ "flat_rate_ats": "", "federal_tax_payable": "Impôt fédéral à payer", "federal_tax_rate": "", + "hit_and_run": "", "ins_addr1": "Adresse Insurance Co.", "ins_city": "Insurance City", "ins_co_id": "ID de la compagnie d'assurance", diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 0e8cbb888..d46ba81d0 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -3700,6 +3700,7 @@ - federal_tax_rate - flat_rate_ats - g_bett_amt + - hit_and_run - id - inproduction - ins_addr1 @@ -3972,6 +3973,7 @@ - federal_tax_rate - flat_rate_ats - g_bett_amt + - hit_and_run - id - inproduction - ins_addr1 @@ -4256,6 +4258,7 @@ - federal_tax_rate - flat_rate_ats - g_bett_amt + - hit_and_run - id - inproduction - ins_addr1 diff --git a/hasura/migrations/1745427508374_alter_table_public_jobs_add_column_hit_and_run/down.sql b/hasura/migrations/1745427508374_alter_table_public_jobs_add_column_hit_and_run/down.sql new file mode 100644 index 000000000..24534298b --- /dev/null +++ b/hasura/migrations/1745427508374_alter_table_public_jobs_add_column_hit_and_run/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 "hit_and_run" boolean +-- null default 'false'; diff --git a/hasura/migrations/1745427508374_alter_table_public_jobs_add_column_hit_and_run/up.sql b/hasura/migrations/1745427508374_alter_table_public_jobs_add_column_hit_and_run/up.sql new file mode 100644 index 000000000..ea504819a --- /dev/null +++ b/hasura/migrations/1745427508374_alter_table_public_jobs_add_column_hit_and_run/up.sql @@ -0,0 +1,2 @@ +alter table "public"."jobs" add column "hit_and_run" boolean + null default 'false'; From 03241778fa24938e8b457240f06ef5d2388b9bae Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Wed, 23 Apr 2025 13:10:42 -0700 Subject: [PATCH 026/195] IO-3212 ACV Amount Signed-off-by: Allan Carr --- .../jobs-detail-general/jobs-detail-general.component.jsx | 3 +++ client/src/translations/en_us/common.json | 1 + client/src/translations/es/common.json | 1 + client/src/translations/fr/common.json | 1 + hasura/metadata/tables.yaml | 6 ++++++ .../down.sql | 4 ++++ .../up.sql | 2 ++ 7 files changed, 18 insertions(+) create mode 100644 hasura/migrations/1745438734125_alter_table_public_jobs_add_column_acv_amount/down.sql create mode 100644 hasura/migrations/1745438734125_alter_table_public_jobs_add_column_acv_amount/up.sql diff --git a/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx b/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx index f36269c83..e31a5e528 100644 --- a/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx +++ b/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx @@ -188,6 +188,9 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) { + + + diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 0a1ec198a..2637d6986 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -1636,6 +1636,7 @@ "actual_completion": "Actual Completion", "actual_delivery": "Actual Delivery", "actual_in": "Actual In", + "acv_amount": "ACV Amount", "adjustment_bottom_line": "Adjustments", "adjustmenthours": "Adjustment Hours", "alt_transport": "Alt. Trans.", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 820fd2973..e51739dd6 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -1636,6 +1636,7 @@ "actual_completion": "Realización real", "actual_delivery": "Entrega real", "actual_in": "Real en", + "acv_amount": "", "adjustment_bottom_line": "Ajustes", "adjustmenthours": "", "alt_transport": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index cd799fec9..8f4f0ce39 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -1637,6 +1637,7 @@ "actual_completion": "Achèvement réel", "actual_delivery": "Livraison réelle", "actual_in": "En réel", + "acv_amount": "", "adjustment_bottom_line": "Ajustements", "adjustmenthours": "", "alt_transport": "", diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 0e8cbb888..32108a1b7 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -3595,6 +3595,7 @@ - actual_completion - actual_delivery - actual_in + - acv_amount - adj_g_disc - adj_strdis - adj_towdis @@ -3700,6 +3701,7 @@ - federal_tax_rate - flat_rate_ats - g_bett_amt + - hit_and_run - id - inproduction - ins_addr1 @@ -3866,6 +3868,7 @@ - actual_completion - actual_delivery - actual_in + - acv_amount - adj_g_disc - adj_strdis - adj_towdis @@ -3972,6 +3975,7 @@ - federal_tax_rate - flat_rate_ats - g_bett_amt + - hit_and_run - id - inproduction - ins_addr1 @@ -4150,6 +4154,7 @@ - actual_completion - actual_delivery - actual_in + - acv_amount - adj_g_disc - adj_strdis - adj_towdis @@ -4256,6 +4261,7 @@ - federal_tax_rate - flat_rate_ats - g_bett_amt + - hit_and_run - id - inproduction - ins_addr1 diff --git a/hasura/migrations/1745438734125_alter_table_public_jobs_add_column_acv_amount/down.sql b/hasura/migrations/1745438734125_alter_table_public_jobs_add_column_acv_amount/down.sql new file mode 100644 index 000000000..61e405200 --- /dev/null +++ b/hasura/migrations/1745438734125_alter_table_public_jobs_add_column_acv_amount/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 "acv_amount" numeric +-- null; diff --git a/hasura/migrations/1745438734125_alter_table_public_jobs_add_column_acv_amount/up.sql b/hasura/migrations/1745438734125_alter_table_public_jobs_add_column_acv_amount/up.sql new file mode 100644 index 000000000..14af4c806 --- /dev/null +++ b/hasura/migrations/1745438734125_alter_table_public_jobs_add_column_acv_amount/up.sql @@ -0,0 +1,2 @@ +alter table "public"."jobs" add column "acv_amount" numeric + null; From 55944257aa89b35479a5f377d83ce15767619e8d Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Wed, 23 Apr 2025 13:16:49 -0700 Subject: [PATCH 027/195] IO-3212 ACV Amount Signed-off-by: Allan Carr --- client/src/graphql/jobs.queries.js | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js index b31c5c17a..2c5dcd314 100644 --- a/client/src/graphql/jobs.queries.js +++ b/client/src/graphql/jobs.queries.js @@ -423,6 +423,7 @@ export const GET_JOB_BY_PK = gql` actual_completion actual_delivery actual_in + acv_amount adjustment_bottom_line alt_transport area_of_damage From 12c87ed68953297d7a4c8ce124079b9b98d902b3 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Wed, 23 Apr 2025 18:48:35 -0700 Subject: [PATCH 028/195] IO-3217 OTSL Labor Type Signed-off-by: Allan Carr --- .../jobs-available-table.container.jsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) 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 0e41c3997..c27423821 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 @@ -4,11 +4,12 @@ import { Col, Row } from "antd"; import Axios from "axios"; import _ from "lodash"; import queryString from "query-string"; -import React, { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { useLocation, useNavigate } from "react-router-dom"; import { createStructuredSelector } from "reselect"; +import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { logImEXEvent } from "../../firebase/firebase.utils"; import { DELETE_AVAILABLE_JOB, @@ -33,7 +34,6 @@ import OwnerFindModalContainer from "../owner-find-modal/owner-find-modal.contai import { GetSupplementDelta } from "./jobs-available-supplement.estlines.util"; import HeaderFields from "./jobs-available-supplement.headerfields"; import JobsAvailableTableComponent from "./jobs-available-table.component"; -import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -195,7 +195,7 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail await deleteJob({ variables: { id: estData.id } - }).then((r) => { + }).then(() => { refetch(); setInsertLoading(false); }); @@ -315,7 +315,7 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail deleteJob({ variables: { id: estData.id } - }).then((r) => { + }).then(() => { refetch(); setInsertLoading(false); }); @@ -372,7 +372,7 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail loadEstData({ variables: { id: record.id } }); modalSearchState[1](record.clm_no); setJobModalVisible(true); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line }, []); useEffect(() => { @@ -456,7 +456,7 @@ function replaceEmpty(someObj, replaceValue = null) { return JSON.parse(temp); } -async function CheckTaxRatesUSA(estData, bodyshop) { +async function CheckTaxRatesUSA(estData) { if (!estData.parts_tax_rates?.PAM) { estData.parts_tax_rates.PAM = estData.parts_tax_rates.PAC; } @@ -568,7 +568,7 @@ async function CheckTaxRates(estData, bodyshop) { }); //} } -function ResolveCCCLineIssues(estData, bodyshop) { +function ResolveCCCLineIssues(estData) { //Find all misc amounts, populate them to the act price. //This needs to be done before cleansing unq_seq since some misc prices could move over. estData.joblines.data.forEach((line) => { @@ -585,6 +585,9 @@ function ResolveCCCLineIssues(estData, bodyshop) { // line.notes += ` | ET/UT Update (prev = ${line.mod_lbr_ty})`; line.mod_lbr_ty = "LAR"; } + if (line.mod_lbr_ty === "OTSL") { + line.mod_lbr_ty = line.mod_lbr_hrs === 0 ? null : "LAB"; + } } }); }); From fc03e5f983fb0ab98303ff121dc1c2c8ffaf7cba Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Thu, 24 Apr 2025 13:32:27 -0700 Subject: [PATCH 029/195] IO-3220 VPB Board Settings Popup Signed-off-by: Allan Carr --- .../settings/FilterSettings.jsx | 3 +-- .../settings/InformationSettings.jsx | 7 +++---- .../settings/LayoutSettings.jsx | 7 +++---- .../settings/StatisticsSettings.jsx | 5 ++--- ...roduction-board-kanban.settings.component.jsx | 16 ++++++++-------- 5 files changed, 17 insertions(+), 21 deletions(-) diff --git a/client/src/components/production-board-kanban/settings/FilterSettings.jsx b/client/src/components/production-board-kanban/settings/FilterSettings.jsx index 3bc8ce23e..61f920b01 100644 --- a/client/src/components/production-board-kanban/settings/FilterSettings.jsx +++ b/client/src/components/production-board-kanban/settings/FilterSettings.jsx @@ -1,7 +1,6 @@ -import React from "react"; import { Card, Form, Select } from "antd"; -import { useTranslation } from "react-i18next"; import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; const FilterSettings = ({ selectedMdInsCos, diff --git a/client/src/components/production-board-kanban/settings/InformationSettings.jsx b/client/src/components/production-board-kanban/settings/InformationSettings.jsx index 3725d1ab8..ddf88c987 100644 --- a/client/src/components/production-board-kanban/settings/InformationSettings.jsx +++ b/client/src/components/production-board-kanban/settings/InformationSettings.jsx @@ -1,10 +1,9 @@ import { Card, Checkbox, Col, Form, Row } from "antd"; -import React from "react"; import PropTypes from "prop-types"; const InformationSettings = ({ t }) => ( - - + + {[ "model_info", "ownr_nm", @@ -21,7 +20,7 @@ const InformationSettings = ({ t }) => ( "subtotal", "tasks" ].map((item) => ( - + {t(`production.labels.${item}`)} diff --git a/client/src/components/production-board-kanban/settings/LayoutSettings.jsx b/client/src/components/production-board-kanban/settings/LayoutSettings.jsx index 9c062e651..888503df2 100644 --- a/client/src/components/production-board-kanban/settings/LayoutSettings.jsx +++ b/client/src/components/production-board-kanban/settings/LayoutSettings.jsx @@ -1,9 +1,8 @@ import { Card, Col, Form, Radio, Row } from "antd"; -import React from "react"; import PropTypes from "prop-types"; const LayoutSettings = ({ t }) => ( - + {[ { @@ -48,9 +47,9 @@ const LayoutSettings = ({ t }) => ( ] } ].map(({ name, label, options }) => ( - + - + {options.map((option) => ( {option.label} diff --git a/client/src/components/production-board-kanban/settings/StatisticsSettings.jsx b/client/src/components/production-board-kanban/settings/StatisticsSettings.jsx index 28e476fad..1645bc05d 100644 --- a/client/src/components/production-board-kanban/settings/StatisticsSettings.jsx +++ b/client/src/components/production-board-kanban/settings/StatisticsSettings.jsx @@ -1,8 +1,7 @@ +import { Card, Checkbox, Form } from "antd"; +import PropTypes from "prop-types"; import { DragDropContext, Draggable, Droppable } from "../trello-board/dnd/lib/index.js"; import { statisticsItems } from "./defaultKanbanSettings.js"; -import { Card, Checkbox, Form } from "antd"; -import React from "react"; -import PropTypes from "prop-types"; const StatisticsSettings = ({ t, statisticsOrder, setStatisticsOrder, setHasChanges }) => { const onDragEnd = (result) => { diff --git a/client/src/components/production-board-kanban/settings/production-board-kanban.settings.component.jsx b/client/src/components/production-board-kanban/settings/production-board-kanban.settings.component.jsx index d6ae173fa..2f886a8fd 100644 --- a/client/src/components/production-board-kanban/settings/production-board-kanban.settings.component.jsx +++ b/client/src/components/production-board-kanban/settings/production-board-kanban.settings.component.jsx @@ -1,17 +1,17 @@ +import { SettingOutlined } from "@ant-design/icons"; import { useMutation } from "@apollo/client"; import { Button, Card, Col, Form, Popover, Row, Tabs } from "antd"; +import { isFunction } from "lodash"; +import PropTypes from "prop-types"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useNotification } from "../../../contexts/Notifications/notificationContext.jsx"; import { UPDATE_KANBAN_SETTINGS } from "../../../graphql/user.queries.js"; import { defaultKanbanSettings, mergeWithDefaults } from "./defaultKanbanSettings.js"; -import LayoutSettings from "./LayoutSettings.jsx"; -import InformationSettings from "./InformationSettings.jsx"; -import StatisticsSettings from "./StatisticsSettings.jsx"; import FilterSettings from "./FilterSettings.jsx"; -import PropTypes from "prop-types"; -import { isFunction } from "lodash"; -import { useNotification } from "../../../contexts/Notifications/notificationContext.jsx"; -import { SettingOutlined } from "@ant-design/icons"; +import InformationSettings from "./InformationSettings.jsx"; +import LayoutSettings from "./LayoutSettings.jsx"; +import StatisticsSettings from "./StatisticsSettings.jsx"; function ProductionBoardKanbanSettings({ associationSettings, parentLoading, bodyshop, data, onSettingsChange }) { const [form] = Form.useForm(); @@ -87,7 +87,7 @@ function ProductionBoardKanbanSettings({ associationSettings, parentLoading, bod }; const overlay = ( - + Date: Fri, 25 Apr 2025 11:54:36 -0400 Subject: [PATCH 030/195] release/2025-04-25 - Add logging around handleInvoiceBasePayment paymentResponse, toned logs down. fixed issue in paymentResponseResults --- .../lib/handleInvoiceBasedPayment.js | 31 +++++++----- server/notifications/scenarioParser.js | 48 ++++++++++++------- 2 files changed, 49 insertions(+), 30 deletions(-) diff --git a/server/intellipay/lib/handleInvoiceBasedPayment.js b/server/intellipay/lib/handleInvoiceBasedPayment.js index 21e4500a6..d5fc97b9c 100644 --- a/server/intellipay/lib/handleInvoiceBasedPayment.js +++ b/server/intellipay/lib/handleInvoiceBasedPayment.js @@ -107,18 +107,25 @@ const handleInvoiceBasedPayment = async (values, logger, logMeta, res) => { }); // Create payment response record - const responseResults = await gqlClient.request(INSERT_PAYMENT_RESPONSE, { - paymentResponse: { - amount: values.total, - bodyshopid: bodyshop.id, - paymentid: paymentResult.id, - jobid: job.id, - declinereason: "Approved", - ext_paymentid: values.paymentid, - successful: true, - response: values - } - }); + const responseResults = await gqlClient + .request(INSERT_PAYMENT_RESPONSE, { + paymentResponse: { + amount: values.total, + bodyshopid: bodyshop.id, + paymentid: paymentResult.insert_payments.returning[0].id, + jobid: job.id, + declinereason: "Approved", + ext_paymentid: values.paymentid, + successful: true, + response: values + } + }) + .catch((err) => { + logger.log("intellipay-postback-invoice-response-error", "ERROR", "api", null, { + err, + ...logMeta + }); + }); logger.log("intellipay-postback-invoice-response-success", "DEBUG", "api", null, { responseResults, diff --git a/server/notifications/scenarioParser.js b/server/notifications/scenarioParser.js index ddf1ff103..aebec8205 100644 --- a/server/notifications/scenarioParser.js +++ b/server/notifications/scenarioParser.js @@ -63,7 +63,9 @@ const scenarioParser = async (req, jobIdField) => { } if (!jobId) { - logger.log(`No jobId found using path "${jobIdField}", skipping notification parsing`, "info", "notifications"); + if (process?.env?.NODE_ENV === "development") { + logger.log(`No jobId found using path "${jobIdField}", skipping notification parsing`, "info", "notifications"); + } return; } @@ -88,7 +90,9 @@ const scenarioParser = async (req, jobIdField) => { // Exit early if no job watchers are found for this job if (isEmpty(jobWatchers)) { - logger.log(`No watchers found for jobId "${jobId}", skipping notification parsing`, "info", "notifications"); + if (process?.env?.NODE_ENV === "development") { + logger.log(`No watchers found for jobId "${jobId}", skipping notification parsing`, "info", "notifications"); + } return; } @@ -130,11 +134,13 @@ const scenarioParser = async (req, jobIdField) => { // Exit early if no matching scenarios are identified if (isEmpty(matchingScenarios)) { - logger.log( - `No matching scenarios found for jobId "${jobId}", skipping notification dispatch`, - "info", - "notifications" - ); + if (process?.env?.NODE_ENV === "development") { + logger.log( + `No matching scenarios found for jobId "${jobId}", skipping notification dispatch`, + "info", + "notifications" + ); + } return; } @@ -157,11 +163,13 @@ const scenarioParser = async (req, jobIdField) => { // Exit early if no notification associations are found if (isEmpty(associationsData?.associations)) { - logger.log( - `No notification associations found for jobId "${jobId}", skipping notification dispatch`, - "info", - "notifications" - ); + if (process?.env?.NODE_ENV === "development") { + logger.log( + `No notification associations found for jobId "${jobId}", skipping notification dispatch`, + "info", + "notifications" + ); + } return; } @@ -196,11 +204,13 @@ const scenarioParser = async (req, jobIdField) => { // Exit early if no scenarios have eligible watchers after filtering if (isEmpty(finalScenarioData?.matchingScenarios)) { - logger.log( - `No eligible watchers after filtering for jobId "${jobId}", skipping notification dispatch`, - "info", - "notifications" - ); + if (process?.env?.NODE_ENV === "development") { + logger.log( + `No eligible watchers after filtering for jobId "${jobId}", skipping notification dispatch`, + "info", + "notifications" + ); + } return; } @@ -259,7 +269,9 @@ const scenarioParser = async (req, jobIdField) => { } if (isEmpty(scenariosToDispatch)) { - logger.log(`No scenarios to dispatch for jobId "${jobId}" after building`, "info", "notifications"); + if (process?.env?.NODE_ENV === "development") { + logger.log(`No scenarios to dispatch for jobId "${jobId}" after building`, "info", "notifications"); + } return; } From 8687214420092687478f5ba77223367b68c07a10 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Fri, 25 Apr 2025 11:58:47 -0400 Subject: [PATCH 031/195] release/2025-04-25 - update handleInvoiceBasedPayment.test.js --- server/intellipay/lib/tests/handleInvoiceBasedPayment.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/intellipay/lib/tests/handleInvoiceBasedPayment.test.js b/server/intellipay/lib/tests/handleInvoiceBasedPayment.test.js index 01352191e..ce15861eb 100644 --- a/server/intellipay/lib/tests/handleInvoiceBasedPayment.test.js +++ b/server/intellipay/lib/tests/handleInvoiceBasedPayment.test.js @@ -37,7 +37,9 @@ beforeEach(() => { ] }) .mockResolvedValueOnce({ - id: "payment123" + insert_payments: { + returning: [{ id: "payment123" }] + } }) .mockResolvedValueOnce({ insert_payment_response: { From 3fe0e3a33cfbdb4b45cd5e3a9f483024f22b0f41 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Fri, 25 Apr 2025 09:39:20 -0700 Subject: [PATCH 032/195] IO-3222 Vendor Name Open Search Signed-off-by: Allan Carr --- server/opensearch/os-handler.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server/opensearch/os-handler.js b/server/opensearch/os-handler.js index 48310c4f2..3292233c9 100644 --- a/server/opensearch/os-handler.js +++ b/server/opensearch/os-handler.js @@ -64,7 +64,7 @@ async function OpenSearchUpdateHandler(req, res) { document = pick(req.body.event.data.new, ["id", "ownr_fn", "ownr_ln", "ownr_co_nm", "ownr_ph1", "ownr_ph2"]); document.bodyshopid = req.body.event.data.new.shopid; break; - case "bills": + case "bills": { const bill = await client.request( `query ADMIN_GET_BILL_BY_ID($billId: uuid!) { bills_by_pk(id: $billId) { @@ -97,7 +97,8 @@ async function OpenSearchUpdateHandler(req, res) { bodyshopid: bill.bills_by_pk.job.shopid }; break; - case "payments": + } + case "payments": { //Query to get the job and RO number const payment = await client.request( @@ -141,6 +142,7 @@ async function OpenSearchUpdateHandler(req, res) { bodyshopid: payment.payments_by_pk.job.shopid }; break; + } } const payload = { id: req.body.event.data.new.id, @@ -255,6 +257,7 @@ async function OpenSearchSearchHandler(req, res) { "*ownr_co_nm^8", "*ownr_ph1^8", "*ownr_ph2^8", + "*vendor.name^8", "*comment^6" // "*" ] From b5973085e72f7c89cf0b2b745ded936e323483bf Mon Sep 17 00:00:00 2001 From: Patrick Fic Date: Fri, 25 Apr 2025 14:02:40 -0700 Subject: [PATCH 033/195] IO-3223 Add Canny for feature request and change log. --- bodyshop_translations.babel | 1281 ++++++- client/index.html | 44 +- client/package-lock.json | 2893 ++++++++++------ .../update-alert/update-alert.component.jsx | 2 +- .../feature-request/feature-request.page.jsx | 42 + .../pages/manage/manage.page.component.jsx | 33 +- client/src/translations/en_us/common.json | 130 +- client/src/translations/es/common.json | 130 +- client/src/translations/fr/common.json | 131 +- client/src/utils/RegisterSw.js | 5 +- client/src/utils/sentry.js | 2 +- package-lock.json | 2929 ++++++++++------- package.json | 1 + server.js | 1 + server/routes/ssoRoutes.js | 13 + server/sso/canny.js | 28 + 16 files changed, 5256 insertions(+), 2409 deletions(-) create mode 100644 client/src/pages/feature-request/feature-request.page.jsx create mode 100644 server/routes/ssoRoutes.js create mode 100644 server/sso/canny.js diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel index d92f00f41..8969c93eb 100644 --- a/bodyshop_translations.babel +++ b/bodyshop_translations.babel @@ -1,4 +1,4 @@ - +
+

Please check the job for more details.

` }); await sendTaskEmail({ @@ -226,6 +234,7 @@ const getQueue = () => { * @param {Object} options.logger - Logger instance for logging dispatch events. * @returns {Promise} Resolves when all notifications are added to the queue. */ +// eslint-disable-next-line no-unused-vars const dispatchEmailsToQueue = async ({ emailsToDispatch, logger }) => { const emailAddQueue = getQueue(); diff --git a/server/routes/adminRoutes.js b/server/routes/adminRoutes.js index 0d62d27a6..a8c0e98b4 100644 --- a/server/routes/adminRoutes.js +++ b/server/routes/adminRoutes.js @@ -2,7 +2,7 @@ const express = require("express"); const router = express.Router(); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); const { createAssociation, createShop, updateShop, updateCounter } = require("../admin/adminops"); -const { updateUser, getUser, createUser } = require("../firebase/firebase-handler"); +const { updateUser, getUser, createUser, sendwelcome, resetlink } = require("../firebase/firebase-handler"); const validateAdminMiddleware = require("../middleware/validateAdminMiddleware"); router.use(validateFirebaseIdTokenMiddleware); @@ -15,5 +15,7 @@ router.post("/updatecounter", updateCounter); router.post("/updateuser", updateUser); router.post("/getuser", getUser); router.post("/createuser", createUser); +router.post("/sendwelcome", sendwelcome); +router.post("/resetlink", resetlink); module.exports = router; From d444821cf74fd65792b037f49fd1d1e2a730909d Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 15 Apr 2025 10:46:49 -0400 Subject: [PATCH 006/195] feature/IO-2282-VSSTA-Integration: - checkpoint --- server/graphql-client/queries.js | 28 ++++ server/integrations/VSSTA/vsstaIntegration.js | 36 ----- .../VSSTA/vsstaIntegrationRoute.js | 123 ++++++++++++++++++ server/integrations/VSSTA/vsstaMiddleware.js | 5 - server/media/imgproxy-media.js | 7 +- server/media/media.js | 31 ++--- server/media/util/determineFileType.js | 17 +++ .../middleware/vsstaIntegrationMiddleware.js | 17 +++ server/routes/intergrationRoutes.js | 4 +- 9 files changed, 205 insertions(+), 63 deletions(-) delete mode 100644 server/integrations/VSSTA/vsstaIntegration.js create mode 100644 server/integrations/VSSTA/vsstaIntegrationRoute.js delete mode 100644 server/integrations/VSSTA/vsstaMiddleware.js create mode 100644 server/media/util/determineFileType.js create mode 100644 server/middleware/vsstaIntegrationMiddleware.js diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 16c955467..fe08897ba 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2853,3 +2853,31 @@ query GET_BODYSHOP_BY_MERCHANTID($merchantID: String!) { email } }`; + +// Define the GraphQL query to get a job by RO number and shop ID +exports.GET_JOB_BY_RO_NUMBER_AND_SHOP_ID = ` + query GET_JOB_BY_RO_NUMBER_AND_SHOP_ID($roNumber: String!, $shopId: String!) { + jobs(where: {ro_number: {_eq: $roNumber}, shopid: {_eq: $shopId}}) { + id + shopid + bodyshopid + bodyshop { + id + email + } + } + } +`; + +// Define the mutation to insert a new document +exports.INSERT_NEW_DOCUMENT = ` + mutation INSERT_NEW_DOCUMENT($docInput: [documents_insert_input!]!) { + insert_documents(objects: $docInput) { + returning { + id + name + key + } + } + } +`; diff --git a/server/integrations/VSSTA/vsstaIntegration.js b/server/integrations/VSSTA/vsstaIntegration.js deleted file mode 100644 index 30f7531a9..000000000 --- a/server/integrations/VSSTA/vsstaIntegration.js +++ /dev/null @@ -1,36 +0,0 @@ -const client = require("../../graphql-client/graphql-client").client; - -/** - * VSSTA Integration - * @param req - * @param res - * @returns {Promise} - */ -const vsstaIntegration = async (req, res) => { - const { logger } = req; - - // Examplwe req.body - //{ - // "shop_id":"test", - // "“ro_nbr“":"71475", - // "vin_nbr":"12345678912345678", - // "pdf_download_link":"https://portal-staging.vssta.com/invoice_data/1500564", - // "“company_api_key“":"xxxxx", - // "scan_type":"PRE", - // "scan_fee":"119.00", - // "scanner_number":"1234", - // "scan_time":"2022-08-23 17:53:50", - // "technician":"Frank Jones", - // "year":"2021", - // "make":"TOYOTA", - // "model":"Tacoma SR5 grade" - // - // } - // 1 - We would want to get the Job by searching the ro_nbr and shop_id (The assumption) - - // 2 - We want to download the file provided from the pdf_download_link and associate (upload) it - // to S3 bucket for media, and insert a document record in the database, the file is base64 encoded (pdf), we will want to unencode it when storing it as a pdf - // We might not have to un-encode it, ultimately we want to send the base64 and the end is a pdf file the user can view from the documents section. -}; - -module.exports = vsstaIntegration; diff --git a/server/integrations/VSSTA/vsstaIntegrationRoute.js b/server/integrations/VSSTA/vsstaIntegrationRoute.js new file mode 100644 index 000000000..77f5094b9 --- /dev/null +++ b/server/integrations/VSSTA/vsstaIntegrationRoute.js @@ -0,0 +1,123 @@ +const axios = require("axios"); +const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3"); +const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); +const { GET_JOB_BY_RO_NUMBER_AND_SHOP_ID, INSERT_NEW_DOCUMENT } = require("../../graphql-client/queries"); +const determineFileType = require("../../media/util/determineFileType"); +const { InstanceRegion } = require("../../utils/instanceMgr"); +const client = require("../../graphql-client/graphql-client").client; + +// Assume these are configured environment variables or constants +const S3_BUCKET = process.env.S3_BUCKET || "your-s3-bucket-name"; + +const vsstaIntegrationRoute = async (req, res) => { + const { logger } = req; + try { + const requiredParams = [ + "shop_id", + "ro_nbr", + "pdf_download_link", + "company_api_key", + "scan_type", + "scan_time", + "technician", + "year", + "make", + "model" + ]; + + const missingParams = requiredParams.filter((param) => !req.body[param]); + + if (missingParams.length > 0) { + logger.error(`Missing required parameters: ${missingParams.join(", ")}`); + return res.status(400).json({ + error: "Missing required parameters", + missingParams + }); + } + + const { shop_id, ro_nbr, pdf_download_link, scan_type, scan_time, technician, year, make, model, company_api_key } = + req.body; + + // 1. Get the job record by ro_number and shop_id + const jobResult = await client.request(GET_JOB_BY_RO_NUMBER_AND_SHOP_ID, { + roNumber: ro_nbr, + shopId: shop_id + }); + + if (!jobResult.jobs || jobResult.jobs.length === 0) { + logger.error(`No job found for RO number ${ro_nbr} and shop ID ${shop_id}`); + return res.status(404).json({ error: "Job not found" }); + } + + const job = jobResult.jobs[0]; + logger.info(`Found job with ID ${job.id} for RO number ${ro_nbr}`); + + // 2. Download the PDF from the provided link + logger.info(`Downloading PDF from ${pdf_download_link}`); + const pdfResponse = await axios.get(pdf_download_link, { + responseType: "arraybuffer", + headers: { + "auth:token": company_api_key + } + }); + + // 3. Generate key for S3 + const timestamp = Date.now(); + const fileName = `VSSTA_${scan_type}_Scan_${timestamp}.pdf`; + const s3Key = `${job.bodyshopid}/${job.id}/${fileName.replace(/[^A-Z0-9]+/gi, "_")}-${timestamp}.pdf`; + + // 4. Generate presigned URL for S3 upload + logger.info(`Generating presigned URL for S3 key ${s3Key}`); + + const s3Client = new S3Client({ region: InstanceRegion() }); + + const putCommand = new PutObjectCommand({ + Bucket: S3_BUCKET, + Key: s3Key, + ContentType: "application/pdf", + StorageClass: "INTELLIGENT_TIERING" + }); + + const presignedUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: 360 }); + + // 5. Upload file to S3 + logger.info(`Uploading PDF to S3 with key ${s3Key}`); + await axios.put(presignedUrl, pdfResponse.data, { + headers: { "Content-Type": "application/pdf" } + }); + + // 6. Create document record in database + const documentMeta = { + jobid: job.id, // Matches jobid (uuid, nullable) + uploaded_by: "VSSTA Integration", // Matches uploaded_by (text) + name: fileName, // Matches name (text, nullable) + key: s3Key, // Matches key (text, default: '0'::text) + type: determineFileType("application/pdf"), // Matches type (text, nullable), using determineFileType + extension: "pdf", // Matches extension (text, nullable) + bodyshopid: job.bodyshopid, // Matches bodyshopid (uuid, nullable) + size: pdfResponse.data.length, // Matches size (integer, default: 0) + takenat: scan_time, // Matches takenat (timestamp with time zone, nullable) + description: `VSSTA ${scan_type} scan for ${year} ${make} ${model}, performed by ${technician} at ${scan_time}` // Not in schema, will be ignored by the database + }; + + const documentInsert = await client.request(INSERT_NEW_DOCUMENT, { + docInput: [documentMeta] + }); + + if (documentInsert.insert_documents?.returning?.length > 0) { + logger.info(`Document created with ID ${documentInsert.insert_documents.returning[0].id}`); + return res.status(200).json({ + message: "VSSTA integration successful", + documentId: documentInsert.insert_documents.returning[0].id + }); + } else { + logger.error("Failed to create document record"); + return res.status(500).json({ error: "Failed to create document record" }); + } + } catch (error) { + logger.error(`VSSTA integration error: ${error.message}`, error); + return res.status(500).json({ error: error.message }); + } +}; + +module.exports = vsstaIntegrationRoute; diff --git a/server/integrations/VSSTA/vsstaMiddleware.js b/server/integrations/VSSTA/vsstaMiddleware.js deleted file mode 100644 index 800f9bfa2..000000000 --- a/server/integrations/VSSTA/vsstaMiddleware.js +++ /dev/null @@ -1,5 +0,0 @@ -const vsstaMiddleware = (req, res, next) => { - next(); -}; - -module.exports = vsstaMiddleware; diff --git a/server/media/imgproxy-media.js b/server/media/imgproxy-media.js index 5790ecfb7..e30aee90e 100644 --- a/server/media/imgproxy-media.js +++ b/server/media/imgproxy-media.js @@ -215,9 +215,10 @@ const downloadFiles = async (req, res) => { params: { Bucket: imgproxyDestinationBucket, Key: archiveKey, Body: passThrough } }); - parallelUploads3.on("httpUploadProgress", (progress) => { - console.log(progress); - }); + // Disabled progress logging for upload, uncomment if needed + // parallelUploads3.on("httpUploadProgress", (progress) => { + // console.log(progress); + // }); await parallelUploads3.done(); diff --git a/server/media/media.js b/server/media/media.js index af9628c8a..1f207ea14 100644 --- a/server/media/media.js +++ b/server/media/media.js @@ -1,7 +1,8 @@ const _ = require("lodash"); const logger = require("../utils/logger"); const client = require("../graphql-client/graphql-client").client; -const queries = require("../graphql-client/queries"); +const determineFileType = require("./util/determineFileType"); +const { DELETE_MEDIA_DOCUMENTS } = require("../graphql-client/queries"); const cloudinary = require("cloudinary").v2; cloudinary.config(process.env.CLOUDINARY_URL); @@ -13,22 +14,26 @@ const createSignedUploadURL = (req, res) => { const downloadFiles = (req, res) => { const { ids } = req.body; + logger.log("media-bulk-download", "DEBUG", req.user.email, ids, null); const url = cloudinary.utils.download_zip_url({ public_ids: ids, flatten_folders: true }); + res.send(url); }; const deleteFiles = async (req, res) => { const { ids } = req.body; - const types = _.groupBy(ids, (x) => DetermineFileType(x.type)); + + const types = _.groupBy(ids, (x) => determineFileType(x.type)); logger.log("media-bulk-delete", "DEBUG", req.user.email, ids, null); const returns = []; + if (types.image) { //delete images @@ -39,8 +44,8 @@ const deleteFiles = async (req, res) => { ) ); } + if (types.video) { - //delete images returns.push( returns.push( await cloudinary.api.delete_resources( types.video.map((x) => x.key), @@ -48,8 +53,8 @@ const deleteFiles = async (req, res) => { ) ); } + if (types.raw) { - //delete images returns.push( returns.push( await cloudinary.api.delete_resources( types.raw.map((x) => `${x.key}.${x.extension}`), @@ -60,6 +65,7 @@ const deleteFiles = async (req, res) => { // Delete it on apollo. const successfulDeletes = []; + returns.forEach((resType) => { Object.keys(resType.deleted).forEach((key) => { if (resType.deleted[key] === "deleted" || resType.deleted[key] === "not_found") { @@ -69,7 +75,7 @@ const deleteFiles = async (req, res) => { }); try { - const result = await client.request(queries.DELETE_MEDIA_DOCUMENTS, { + const result = await client.request(DELETE_MEDIA_DOCUMENTS, { ids: ids.filter((i) => successfulDeletes.includes(i.key)).map((i) => i.id) }); @@ -85,9 +91,11 @@ const deleteFiles = async (req, res) => { const renameKeys = async (req, res) => { const { documents, tojobid } = req.body; + logger.log("media-bulk-rename", "DEBUG", req.user.email, null, documents); const proms = []; + documents.forEach((d) => { proms.push( (async () => { @@ -95,7 +103,7 @@ const renameKeys = async (req, res) => { return { id: d.id, ...(await cloudinary.uploader.rename(d.from, d.to, { - resource_type: DetermineFileType(d.type) + resource_type: determineFileType(d.type) })) }; } catch (error) { @@ -141,17 +149,6 @@ const renameKeys = async (req, res) => { } }; -//Also needs to be updated in upload utility and mobile app. -function DetermineFileType(filetype) { - if (!filetype) return "auto"; - else if (filetype.startsWith("image")) return "image"; - else if (filetype.startsWith("video")) return "video"; - else if (filetype.startsWith("application/pdf")) return "image"; - else if (filetype.startsWith("application")) return "raw"; - - return "auto"; -} - module.exports = { createSignedUploadURL, downloadFiles, diff --git a/server/media/util/determineFileType.js b/server/media/util/determineFileType.js new file mode 100644 index 000000000..9bd8a4732 --- /dev/null +++ b/server/media/util/determineFileType.js @@ -0,0 +1,17 @@ +/** + * @description Determines the file type based on the filetype string. + * @note Also needs to be updated in the mobile app utility. + * @param filetype + * @returns {string} + */ +const determineFileType = (filetype) => { + if (!filetype) return "auto"; + else if (filetype.startsWith("image")) return "image"; + else if (filetype.startsWith("video")) return "video"; + else if (filetype.startsWith("application/pdf")) return "image"; + else if (filetype.startsWith("application")) return "raw"; + + return "auto"; +}; + +module.exports = determineFileType; diff --git a/server/middleware/vsstaIntegrationMiddleware.js b/server/middleware/vsstaIntegrationMiddleware.js new file mode 100644 index 000000000..7739c4a7a --- /dev/null +++ b/server/middleware/vsstaIntegrationMiddleware.js @@ -0,0 +1,17 @@ +/** + * VSSTA Integration Middleware + * @param req + * @param res + * @param next + * @returns {*} + */ +const vsstaIntegrationMiddleware = (req, res, next) => { + if (req.headers["vssta-integration-secret"] !== process.env.VSSTA_INTEGRATION_SECRET) { + return res.status(401).send("Unauthorized"); + } + + req.isIntegrationAuthorized = true; + next(); +}; + +module.exports = vsstaIntegrationMiddleware; diff --git a/server/routes/intergrationRoutes.js b/server/routes/intergrationRoutes.js index 9d3fc20f4..841805675 100644 --- a/server/routes/intergrationRoutes.js +++ b/server/routes/intergrationRoutes.js @@ -1,6 +1,6 @@ const express = require("express"); -const vsstaIntegration = require("../integrations/VSSTA/vsstaIntegration"); -const vsstaMiddleware = require("../integrations/VSSTA/vsstaMiddleware"); +const vsstaIntegration = require("../integrations/VSSTA/vsstaIntegrationRoute"); +const vsstaMiddleware = require("../middleware/vsstaIntegrationMiddleware"); const router = express.Router(); router.post("/vssta", vsstaMiddleware, vsstaIntegration); From 35a7222f5e17d4ad1e40664acf4ef7bcfa612775 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 15 Apr 2025 11:29:44 -0400 Subject: [PATCH 007/195] feature/IO-2282-VSSTA-Integration: - checkpoint --- server/graphql-client/queries.js | 9 +-- .../VSSTA/vsstaIntegrationRoute.js | 55 ++++++++++++------- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index fe08897ba..761550f1d 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2856,15 +2856,10 @@ query GET_BODYSHOP_BY_MERCHANTID($merchantID: String!) { // Define the GraphQL query to get a job by RO number and shop ID exports.GET_JOB_BY_RO_NUMBER_AND_SHOP_ID = ` - query GET_JOB_BY_RO_NUMBER_AND_SHOP_ID($roNumber: String!, $shopId: String!) { - jobs(where: {ro_number: {_eq: $roNumber}, shopid: {_eq: $shopId}}) { + query GET_JOB_BY_RO_NUMBER_AND_SHOP_ID($roNumber: String!, $shopId: uuid!) { + jobs(where: {ro_number: {_eq: $roNumber}, shopid: {_eq: $shopId}}, limit: 1) { id shopid - bodyshopid - bodyshop { - id - email - } } } `; diff --git a/server/integrations/VSSTA/vsstaIntegrationRoute.js b/server/integrations/VSSTA/vsstaIntegrationRoute.js index 77f5094b9..c40fce462 100644 --- a/server/integrations/VSSTA/vsstaIntegrationRoute.js +++ b/server/integrations/VSSTA/vsstaIntegrationRoute.js @@ -2,15 +2,14 @@ const axios = require("axios"); const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3"); const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); const { GET_JOB_BY_RO_NUMBER_AND_SHOP_ID, INSERT_NEW_DOCUMENT } = require("../../graphql-client/queries"); -const determineFileType = require("../../media/util/determineFileType"); const { InstanceRegion } = require("../../utils/instanceMgr"); const client = require("../../graphql-client/graphql-client").client; -// Assume these are configured environment variables or constants -const S3_BUCKET = process.env.S3_BUCKET || "your-s3-bucket-name"; +const S3_BUCKET = process.env.IMGPROXY_DESTINATION_BUCKET; const vsstaIntegrationRoute = async (req, res) => { const { logger } = req; + try { const requiredParams = [ "shop_id", @@ -28,7 +27,10 @@ const vsstaIntegrationRoute = async (req, res) => { const missingParams = requiredParams.filter((param) => !req.body[param]); if (missingParams.length > 0) { - logger.error(`Missing required parameters: ${missingParams.join(", ")}`); + logger.log(`vssta-integration-missing-param`, "error", "api", "vssta", { + params: missingParams + }); + return res.status(400).json({ error: "Missing required parameters", missingParams @@ -45,29 +47,31 @@ const vsstaIntegrationRoute = async (req, res) => { }); if (!jobResult.jobs || jobResult.jobs.length === 0) { - logger.error(`No job found for RO number ${ro_nbr} and shop ID ${shop_id}`); + logger.log(`vssta-integration-missing-ro`, "error", "api", "vssta"); + return res.status(404).json({ error: "Job not found" }); } const job = jobResult.jobs[0]; - logger.info(`Found job with ID ${job.id} for RO number ${ro_nbr}`); + + logger.logger.info(`Found job with ID ${job.id} for RO number ${ro_nbr}`); // 2. Download the PDF from the provided link - logger.info(`Downloading PDF from ${pdf_download_link}`); + logger.logger.info(`Downloading PDF from ${pdf_download_link}`); const pdfResponse = await axios.get(pdf_download_link, { - responseType: "arraybuffer", - headers: { - "auth:token": company_api_key - } + responseType: "arraybuffer" + // headers: { + // "auth:token": company_api_key + // } }); // 3. Generate key for S3 const timestamp = Date.now(); const fileName = `VSSTA_${scan_type}_Scan_${timestamp}.pdf`; - const s3Key = `${job.bodyshopid}/${job.id}/${fileName.replace(/[^A-Z0-9]+/gi, "_")}-${timestamp}.pdf`; + const s3Key = `${job.shopid}/${job.id}/${fileName.replace(/[^A-Z0-9]+/gi, "_")}-${timestamp}.pdf`; // 4. Generate presigned URL for S3 upload - logger.info(`Generating presigned URL for S3 key ${s3Key}`); + logger.logger.info(`Generating presigned URL for S3 key ${s3Key}`); const s3Client = new S3Client({ region: InstanceRegion() }); @@ -81,7 +85,8 @@ const vsstaIntegrationRoute = async (req, res) => { const presignedUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: 360 }); // 5. Upload file to S3 - logger.info(`Uploading PDF to S3 with key ${s3Key}`); + logger.logger.info(`Uploading PDF to S3 with key ${s3Key}`); + await axios.put(presignedUrl, pdfResponse.data, { headers: { "Content-Type": "application/pdf" } }); @@ -92,12 +97,13 @@ const vsstaIntegrationRoute = async (req, res) => { uploaded_by: "VSSTA Integration", // Matches uploaded_by (text) name: fileName, // Matches name (text, nullable) key: s3Key, // Matches key (text, default: '0'::text) - type: determineFileType("application/pdf"), // Matches type (text, nullable), using determineFileType + // type: determineFileType("application/pdf"), // Matches type (text, nullable), using determineFileType + // Ask Patrick why determineFileType just returns image... + type: "application/pdf", // Matches type (text, nullable), extension: "pdf", // Matches extension (text, nullable) - bodyshopid: job.bodyshopid, // Matches bodyshopid (uuid, nullable) + bodyshopid: job.shopid, // Matches bodyshopid (uuid, nullable) size: pdfResponse.data.length, // Matches size (integer, default: 0) - takenat: scan_time, // Matches takenat (timestamp with time zone, nullable) - description: `VSSTA ${scan_type} scan for ${year} ${make} ${model}, performed by ${technician} at ${scan_time}` // Not in schema, will be ignored by the database + takenat: scan_time // Matches takenat (timestamp with time zone, nullable) }; const documentInsert = await client.request(INSERT_NEW_DOCUMENT, { @@ -105,17 +111,24 @@ const vsstaIntegrationRoute = async (req, res) => { }); if (documentInsert.insert_documents?.returning?.length > 0) { - logger.info(`Document created with ID ${documentInsert.insert_documents.returning[0].id}`); + logger.logger.info(`Document created with ID ${documentInsert.insert_documents.returning[0].id}`); return res.status(200).json({ message: "VSSTA integration successful", documentId: documentInsert.insert_documents.returning[0].id }); } else { - logger.error("Failed to create document record"); + logger.log(`vssta-integration-failed-to-create-document-record`, "error", "api", "vssta", { + params: missingParams + }); + return res.status(500).json({ error: "Failed to create document record" }); } } catch (error) { - logger.error(`VSSTA integration error: ${error.message}`, error); + logger.log(`vssta-integration-general`, "error", "api", "vssta", { + error: error?.message, + stack: error?.stack + }); + return res.status(500).json({ error: error.message }); } }; From f09cb7b247ccf246f373e8ae55aa13155bf6ca49 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 15 Apr 2025 12:40:33 -0400 Subject: [PATCH 008/195] feature/IO-2282-VSSTA-Integration: - Finish Integration --- .../VSSTA/vsstaIntegrationRoute.js | 105 +++++++++--------- 1 file changed, 55 insertions(+), 50 deletions(-) diff --git a/server/integrations/VSSTA/vsstaIntegrationRoute.js b/server/integrations/VSSTA/vsstaIntegrationRoute.js index c40fce462..4fa0b800f 100644 --- a/server/integrations/VSSTA/vsstaIntegrationRoute.js +++ b/server/integrations/VSSTA/vsstaIntegrationRoute.js @@ -5,25 +5,34 @@ const { GET_JOB_BY_RO_NUMBER_AND_SHOP_ID, INSERT_NEW_DOCUMENT } = require("../.. const { InstanceRegion } = require("../../utils/instanceMgr"); const client = require("../../graphql-client/graphql-client").client; -const S3_BUCKET = process.env.IMGPROXY_DESTINATION_BUCKET; +const S3_BUCKET = process.env?.IMGPROXY_DESTINATION_BUCKET; + +/** + * @description VSSTA integration route + * @type {string[]} + */ +const requiredParams = [ + "shop_id", + "ro_nbr", + "pdf_download_link", + "company_api_key", + "scan_type", + "scan_time", + "technician", + "year", + "make", + "model" +]; const vsstaIntegrationRoute = async (req, res) => { const { logger } = req; - try { - const requiredParams = [ - "shop_id", - "ro_nbr", - "pdf_download_link", - "company_api_key", - "scan_type", - "scan_time", - "technician", - "year", - "make", - "model" - ]; + if (!S3_BUCKET) { + logger.log("vssta-integration-missing-bucket", "error", "api", "vssta"); + return res.status(500).json({ error: "Improper configuration" }); + } + try { const missingParams = requiredParams.filter((param) => !req.body[param]); if (missingParams.length > 0) { @@ -54,25 +63,24 @@ const vsstaIntegrationRoute = async (req, res) => { const job = jobResult.jobs[0]; - logger.logger.info(`Found job with ID ${job.id} for RO number ${ro_nbr}`); - - // 2. Download the PDF from the provided link - logger.logger.info(`Downloading PDF from ${pdf_download_link}`); + // 2. Download the base64-encoded PDF string from the provided link const pdfResponse = await axios.get(pdf_download_link, { - responseType: "arraybuffer" - // headers: { - // "auth:token": company_api_key - // } + responseType: "text", // Expect base64 string + headers: { + "auth-token": company_api_key + } }); - // 3. Generate key for S3 + // 3. Decode the base64 string to a PDF buffer + const base64String = pdfResponse.data.replace(/^data:application\/pdf;base64,/, ""); + const pdfBuffer = Buffer.from(base64String, "base64"); + + // 4. Generate key for S3 const timestamp = Date.now(); const fileName = `VSSTA_${scan_type}_Scan_${timestamp}.pdf`; const s3Key = `${job.shopid}/${job.id}/${fileName.replace(/[^A-Z0-9]+/gi, "_")}-${timestamp}.pdf`; - // 4. Generate presigned URL for S3 upload - logger.logger.info(`Generating presigned URL for S3 key ${s3Key}`); - + // 5. Generate presigned URL for S3 upload const s3Client = new S3Client({ region: InstanceRegion() }); const putCommand = new PutObjectCommand({ @@ -84,45 +92,42 @@ const vsstaIntegrationRoute = async (req, res) => { const presignedUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: 360 }); - // 5. Upload file to S3 - logger.logger.info(`Uploading PDF to S3 with key ${s3Key}`); - - await axios.put(presignedUrl, pdfResponse.data, { + // 6. Upload the decoded PDF to S3 + await axios.put(presignedUrl, pdfBuffer, { headers: { "Content-Type": "application/pdf" } }); - // 6. Create document record in database + // 7. Create document record in database const documentMeta = { - jobid: job.id, // Matches jobid (uuid, nullable) - uploaded_by: "VSSTA Integration", // Matches uploaded_by (text) - name: fileName, // Matches name (text, nullable) - key: s3Key, // Matches key (text, default: '0'::text) - // type: determineFileType("application/pdf"), // Matches type (text, nullable), using determineFileType - // Ask Patrick why determineFileType just returns image... - type: "application/pdf", // Matches type (text, nullable), - extension: "pdf", // Matches extension (text, nullable) - bodyshopid: job.shopid, // Matches bodyshopid (uuid, nullable) - size: pdfResponse.data.length, // Matches size (integer, default: 0) - takenat: scan_time // Matches takenat (timestamp with time zone, nullable) + jobid: job.id, + uploaded_by: "VSSTA Integration", + name: fileName, + key: s3Key, + type: "application/pdf", + extension: "pdf", + bodyshopid: job.shopid, + size: pdfBuffer.length, + takenat: scan_time }; const documentInsert = await client.request(INSERT_NEW_DOCUMENT, { docInput: [documentMeta] }); - if (documentInsert.insert_documents?.returning?.length > 0) { - logger.logger.info(`Document created with ID ${documentInsert.insert_documents.returning[0].id}`); - return res.status(200).json({ - message: "VSSTA integration successful", - documentId: documentInsert.insert_documents.returning[0].id - }); - } else { + // Reversed flow: check for error case + if (!documentInsert.insert_documents?.returning?.length) { logger.log(`vssta-integration-failed-to-create-document-record`, "error", "api", "vssta", { params: missingParams }); - return res.status(500).json({ error: "Failed to create document record" }); } + + // Success case + logger.logger.info(`Document created with ID ${documentInsert.insert_documents.returning[0].id}`); + return res.status(200).json({ + message: "VSSTA integration successful", + documentId: documentInsert.insert_documents.returning[0].id + }); } catch (error) { logger.log(`vssta-integration-general`, "error", "api", "vssta", { error: error?.message, From 91fe1f4af9a5425b060bf02dec0c1d92acc77a7b Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 15 Apr 2025 12:55:38 -0400 Subject: [PATCH 009/195] feature/IO-2282-VSSTA-Integration: - Finish Integration --- server/graphql-client/queries.js | 3 +++ server/integrations/VSSTA/vsstaIntegrationRoute.js | 10 ++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 761550f1d..83341bebb 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2860,6 +2860,9 @@ exports.GET_JOB_BY_RO_NUMBER_AND_SHOP_ID = ` jobs(where: {ro_number: {_eq: $roNumber}, shopid: {_eq: $shopId}}, limit: 1) { id shopid + bodyshop { + timezone + } } } `; diff --git a/server/integrations/VSSTA/vsstaIntegrationRoute.js b/server/integrations/VSSTA/vsstaIntegrationRoute.js index 4fa0b800f..b9d9ac5bd 100644 --- a/server/integrations/VSSTA/vsstaIntegrationRoute.js +++ b/server/integrations/VSSTA/vsstaIntegrationRoute.js @@ -3,6 +3,7 @@ const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3"); const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); const { GET_JOB_BY_RO_NUMBER_AND_SHOP_ID, INSERT_NEW_DOCUMENT } = require("../../graphql-client/queries"); const { InstanceRegion } = require("../../utils/instanceMgr"); +const moment = require("moment/moment"); const client = require("../../graphql-client/graphql-client").client; const S3_BUCKET = process.env?.IMGPROXY_DESTINATION_BUCKET; @@ -76,9 +77,9 @@ const vsstaIntegrationRoute = async (req, res) => { const pdfBuffer = Buffer.from(base64String, "base64"); // 4. Generate key for S3 - const timestamp = Date.now(); - const fileName = `VSSTA_${scan_type}_Scan_${timestamp}.pdf`; - const s3Key = `${job.shopid}/${job.id}/${fileName.replace(/[^A-Z0-9]+/gi, "_")}-${timestamp}.pdf`; + const timestamp = moment(scan_time).tz(job.bodyshop.timezone).format("YYYYMMDD-HHmmss"); + const fileName = `${timestamp}_VSSTA_${scan_type}_Scan_${technician}_${year}_${make}_${model}`; + const s3Key = `${job.shopid}/${job.id}/${fileName.replace(/[^A-Z0-9]+/gi, "_")}.pdf`; // 5. Generate presigned URL for S3 upload const s3Client = new S3Client({ region: InstanceRegion() }); @@ -114,7 +115,6 @@ const vsstaIntegrationRoute = async (req, res) => { docInput: [documentMeta] }); - // Reversed flow: check for error case if (!documentInsert.insert_documents?.returning?.length) { logger.log(`vssta-integration-failed-to-create-document-record`, "error", "api", "vssta", { params: missingParams @@ -122,8 +122,6 @@ const vsstaIntegrationRoute = async (req, res) => { return res.status(500).json({ error: "Failed to create document record" }); } - // Success case - logger.logger.info(`Document created with ID ${documentInsert.insert_documents.returning[0].id}`); return res.status(200).json({ message: "VSSTA integration successful", documentId: documentInsert.insert_documents.returning[0].id From 0b7a23d5552ce9eb00849eef7714ed526aed88f0 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 15 Apr 2025 13:02:54 -0400 Subject: [PATCH 010/195] feature/IO-2282-VSSTA-Integration: - include some tests for media utils --- server/media/tests/media-utils.test.js | 98 ++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 server/media/tests/media-utils.test.js diff --git a/server/media/tests/media-utils.test.js b/server/media/tests/media-utils.test.js new file mode 100644 index 000000000..b25678da1 --- /dev/null +++ b/server/media/tests/media-utils.test.js @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import determineFileType from "../util/determineFileType"; +import base64UrlEncode from "../util/base64UrlEncode"; + +describe("Media Utils", () => { + describe("base64UrlEncode", () => { + it("should encode string to base64url format", () => { + expect(base64UrlEncode("hello world")).toBe("aGVsbG8gd29ybGQ"); + }); + + it('should replace "+" with "-"', () => { + // '+' in base64 appears when encoding specific binary data + expect(base64UrlEncode("hello+world")).toBe("aGVsbG8rd29ybGQ"); + }); + + it('should replace "/" with "_"', () => { + expect(base64UrlEncode("path/to/resource")).toBe("cGF0aC90by9yZXNvdXJjZQ"); + }); + + it('should remove trailing "=" characters', () => { + // Using a string that will produce padding in base64 + expect(base64UrlEncode("padding==")).toBe("cGFkZGluZz09"); + }); + }); + + describe("createHmacSha256", () => { + let createHmacSha256; + const originalEnv = process.env; + + beforeEach(async () => { + vi.resetModules(); + process.env = { ...originalEnv }; + process.env.IMGPROXY_KEY = "test-key"; + + // Dynamically import the module after setting env var + const module = await import("../util/createHmacSha256"); + createHmacSha256 = module.default; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("should create a valid HMAC SHA-256 hash", () => { + const result = createHmacSha256("test-data"); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + it("should produce consistent hashes for the same input", () => { + const hash1 = createHmacSha256("test-data"); + const hash2 = createHmacSha256("test-data"); + expect(hash1).toBe(hash2); + }); + + it("should produce different hashes for different inputs", () => { + const hash1 = createHmacSha256("test-data-1"); + const hash2 = createHmacSha256("test-data-2"); + expect(hash1).not.toBe(hash2); + }); + }); + + describe("determineFileType", () => { + it('should return "auto" when no filetype is provided', () => { + expect(determineFileType()).toBe("auto"); + expect(determineFileType(null)).toBe("auto"); + expect(determineFileType(undefined)).toBe("auto"); + }); + + it('should return "image" for image filetypes', () => { + expect(determineFileType("image/jpeg")).toBe("image"); + expect(determineFileType("image/png")).toBe("image"); + expect(determineFileType("image/gif")).toBe("image"); + }); + + it('should return "video" for video filetypes', () => { + expect(determineFileType("video/mp4")).toBe("video"); + expect(determineFileType("video/quicktime")).toBe("video"); + expect(determineFileType("video/x-msvideo")).toBe("video"); + }); + + it('should return "image" for PDF files', () => { + expect(determineFileType("application/pdf")).toBe("image"); + }); + + it('should return "raw" for other application types', () => { + expect(determineFileType("application/zip")).toBe("raw"); + expect(determineFileType("application/json")).toBe("raw"); + expect(determineFileType("application/msword")).toBe("raw"); + }); + + it('should return "auto" for unrecognized types', () => { + expect(determineFileType("audio/mpeg")).toBe("auto"); + expect(determineFileType("text/html")).toBe("auto"); + expect(determineFileType("unknown-type")).toBe("auto"); + }); + }); +}); From 6035d9440414b658805fcee2932ca31a9e73b166 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 15 Apr 2025 13:05:42 -0400 Subject: [PATCH 011/195] feature/IO-2282-VSSTA-Integration: - doc blocks / cleanup --- server/media/media.js | 22 ++++++++++++++++++++++ server/media/util/base64UrlEncode.js | 5 +++++ server/media/util/createHmacSha256.js | 5 +++++ 3 files changed, 32 insertions(+) diff --git a/server/media/media.js b/server/media/media.js index 1f207ea14..7cb8a1b5d 100644 --- a/server/media/media.js +++ b/server/media/media.js @@ -7,11 +7,21 @@ const { DELETE_MEDIA_DOCUMENTS } = require("../graphql-client/queries"); const cloudinary = require("cloudinary").v2; cloudinary.config(process.env.CLOUDINARY_URL); +/** + * @description Creates a signed upload URL for Cloudinary. + * @param req + * @param res + */ const createSignedUploadURL = (req, res) => { logger.log("media-signed-upload", "DEBUG", req.user.email, null, null); res.send(cloudinary.utils.api_sign_request(req.body, process.env.CLOUDINARY_API_SECRET)); }; +/** + * @description Downloads files from Cloudinary. + * @param req + * @param res + */ const downloadFiles = (req, res) => { const { ids } = req.body; @@ -25,6 +35,12 @@ const downloadFiles = (req, res) => { res.send(url); }; +/** + * @description Deletes files from Cloudinary and Apollo. + * @param req + * @param res + * @returns {Promise} + */ const deleteFiles = async (req, res) => { const { ids } = req.body; @@ -89,6 +105,12 @@ const deleteFiles = async (req, res) => { } }; +/** + * @description Renames keys in Cloudinary and updates the database. + * @param req + * @param res + * @returns {Promise} + */ const renameKeys = async (req, res) => { const { documents, tojobid } = req.body; diff --git a/server/media/util/base64UrlEncode.js b/server/media/util/base64UrlEncode.js index 4094148b3..24537cb2c 100644 --- a/server/media/util/base64UrlEncode.js +++ b/server/media/util/base64UrlEncode.js @@ -1,3 +1,8 @@ +/** + * @description Converts a string to a base64url encoded string. + * @param str + * @returns {string} + */ const base64UrlEncode = (str) => Buffer.from(str).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); diff --git a/server/media/util/createHmacSha256.js b/server/media/util/createHmacSha256.js index 05b7d52a3..6be9d6022 100644 --- a/server/media/util/createHmacSha256.js +++ b/server/media/util/createHmacSha256.js @@ -2,6 +2,11 @@ const crypto = require("crypto"); const imgproxyKey = process.env.IMGPROXY_KEY; +/** + * @description Creates a HMAC SHA-256 hash of the given data. + * @param data + * @returns {string} + */ const createHmacSha256 = (data) => crypto.createHmac("sha256", imgproxyKey).update(data).digest("base64url"); module.exports = createHmacSha256; From 30f34a17eae88263a988338def44853f50667d21 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 15 Apr 2025 13:20:07 -0400 Subject: [PATCH 012/195] feature/IO-2282-VSSTA-Integration: - doc blocks / cleanup --- server/notifications/scenarioBuilders.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/notifications/scenarioBuilders.js b/server/notifications/scenarioBuilders.js index b3f4d0fd2..d1bdb22a0 100644 --- a/server/notifications/scenarioBuilders.js +++ b/server/notifications/scenarioBuilders.js @@ -182,7 +182,7 @@ const newMediaAddedReassignedBuilder = (data) => { : data.changedFields?.jobid && data.changedFields.jobid.old !== data.changedFields.jobid.new ? "moved to this job" : "updated"; - const body = `An ${mediaType} has been ${action}.`; + const body = `A ${mediaType} has been ${action}.`; return buildNotification(data, "notifications.job.newMediaAdded", body, { mediaType, From 0e75f54d6e25da919e07eb8a3caced3d4a9bf9d3 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 15 Apr 2025 13:39:34 -0400 Subject: [PATCH 013/195] feature/IO-2282-VSSTA-Integration: - doc blocks / cleanup --- server/integrations/VSSTA/vsstaIntegrationRoute.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/integrations/VSSTA/vsstaIntegrationRoute.js b/server/integrations/VSSTA/vsstaIntegrationRoute.js index b9d9ac5bd..c98b689ca 100644 --- a/server/integrations/VSSTA/vsstaIntegrationRoute.js +++ b/server/integrations/VSSTA/vsstaIntegrationRoute.js @@ -1,3 +1,7 @@ +// Notes: At the moment we take in RO Number, and ShopID. This is not very good considering the RO number can often be null, need +// to ask if it is possible that we just send the Job ID itself, this way we don't need to really care about the bodyshop, and we +// don't risk getting a null + const axios = require("axios"); const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3"); const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); From 546ebba0bd88b90ce51138687e7a9756a7628248 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 15 Apr 2025 13:57:50 -0400 Subject: [PATCH 014/195] feature/IO-3187-Admin-Enhancements - Minor cleanup --- server/firebase/firebase-handler.js | 204 +++++++++++----------------- server/graphql-client/queries.js | 18 +++ server/routes/adminRoutes.js | 6 +- 3 files changed, 100 insertions(+), 128 deletions(-) diff --git a/server/firebase/firebase-handler.js b/server/firebase/firebase-handler.js index 48c2baf33..f88d352f1 100644 --- a/server/firebase/firebase-handler.js +++ b/server/firebase/firebase-handler.js @@ -1,15 +1,10 @@ -const path = require("path"); -require("dotenv").config({ - path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) -}); - -const admin = require("firebase-admin"); -const logger = require("../utils/logger"); -const { sendWelcomeEmail } = require("../email/sendemail"); -const client = require("../graphql-client/graphql-client").client; const serviceAccount = require(process.env.FIREBASE_ADMINSDK_JSON); +const admin = require("firebase-admin"); const moment = require("moment-timezone"); -//const generateEmailTemplate = require("../email/generateTemplate"); +const logger = require("../utils/logger"); +const client = require("../graphql-client/graphql-client").client; +const { sendWelcomeEmail } = require("../email/sendemail"); +const { GET_USER_BY_EMAIL } = require("../graphql-client/queries"); admin.initializeApp({ credential: admin.credential.cert(serviceAccount), @@ -202,112 +197,89 @@ const unsubscribe = async (req, res) => { } }; -const sendwelcome = (req, res) => { +const getWelcomeEmail = async (req, res) => { const { authid, email } = req.body; - // Fetch user from Firebase - admin - .auth() - .getUser(authid) - .then((userRecord) => { - if (!userRecord) { - return Promise.reject({ status: 404, message: "User not found in Firebase." }); - } + try { + // Fetch user from Firebase + const userRecord = await admin.auth().getUser(authid); + if (!userRecord) { + throw { status: 404, message: "User not found in Firebase." }; + } - // Fetch user data from the database using GraphQL - return client.request( - ` - query GET_USER_BY_EMAIL($email: String!) { - users(where: { email: { _eq: $email } }) { - email - validemail - associations { - id - shopid - bodyshop { - id - convenient_company - features - timezone - } - } - } - }`, - { email: email.toLowerCase() } - ); - }) - .then((dbUserResult) => { - const dbUser = dbUserResult?.users?.[0]; - if (!dbUser) { - return Promise.reject({ status: 404, message: "User not found in database." }); - } - // Validate email before proceeding - if (!dbUser.validemail) { - logger.log("admin-send-welcome-email-skip", "debug", req.user.email, null, { - message: "User email is not valid, skipping email.", - email - }); - return res.status(200).json({ message: "User email is not valid, email not sent." }); - } + // Fetch user data from the database using GraphQL + const dbUserResult = await client.request(GET_USER_BY_EMAIL, { email: email.toLowerCase() }); - // Generate password reset link - return admin - .auth() - .generatePasswordResetLink(dbUser.email) - .then((resetLink) => ({ dbUser, resetLink })); - }) - .then(({ dbUser, resetLink }) => { - // Send welcome email (replace with your actual email-sending service) - return sendWelcomeEmail({ - to: dbUser.email, - resetLink, - dateLine: moment().tz(dbUser.associations?.[0]?.bodyshop?.timezone).format("MM/DD/YYYY @ hh:mm a"), - features: dbUser.associations?.[0]?.bodyshop?.features + const dbUser = dbUserResult?.users?.[0]; + if (!dbUser) { + throw { status: 404, message: "User not found in database." }; + } + + // Validate email before proceeding + if (!dbUser.validemail) { + logger.log("admin-send-welcome-email-skip", "debug", req.user.email, null, { + message: "User email is not valid, skipping email.", + email }); - }) - .then(() => { - // Log success and return response - logger.log("admin-send-welcome-email", "debug", req.user.email, null, { - request: req.body, - ioadmin: true, - emailSentTo: email - }); - res.status(200).json({ message: "Welcome email sent successfully." }); - }) - .catch((error) => { - logger.log("admin-send-welcome-email-error", "ERROR", req.user.email, null, { error }); + return res.status(200).json({ message: "User email is not valid, email not sent." }); + } - if (!res.headersSent) { - res.status(error.status || 500).json({ - message: error.message || "Error sending welcome email.", - error - }); - } + // Generate password reset link + const resetLink = await admin.auth().generatePasswordResetLink(dbUser.email); + + // Send welcome email + await sendWelcomeEmail({ + to: dbUser.email, + resetLink, + dateLine: moment().tz(dbUser.associations?.[0]?.bodyshop?.timezone).format("MM/DD/YYYY @ hh:mm a"), + features: dbUser.associations?.[0]?.bodyshop?.features }); + + // Log success and return response + logger.log("admin-send-welcome-email", "debug", req.user.email, null, { + request: req.body, + ioadmin: true, + emailSentTo: email + }); + res.status(200).json({ message: "Welcome email sent successfully." }); + } catch (error) { + logger.log("admin-send-welcome-email-error", "ERROR", req.user.email, null, { error }); + + if (!res.headersSent) { + res.status(error.status || 500).json({ + message: error.message || "Error sending welcome email.", + error + }); + } + } }; -const resetlink = (req, res) => { +const getResetLink = async (req, res) => { const { authid, email } = req.body; - logger.log("admin-reset-link", "debug", req.user.email, null, { authid: authid, email: email }); - admin - .auth() - .getUser(authid) - .then((userRecord) => { - if (!userRecord) { - return Promise.reject({ status: 404, message: "User not found in Firebase." }); - } - return admin - .auth() - .generatePasswordResetLink(email) - .then((resetLink) => ({ userRecord, resetLink })); - }) - .then(({ resetLink }) => { - logger.log("admin-reset-link-success", "debug", req.user.email, null, { - request: req.body, - ioadmin: true, - }); - res.status(200).json({ message: "Reset link generated successfully.", resetLink }); + logger.log("admin-reset-link", "debug", req.user.email, null, { authid, email }); + + try { + // Fetch user from Firebase + const userRecord = await admin.auth().getUser(authid); + if (!userRecord) { + throw { status: 404, message: "User not found in Firebase." }; + } + + // Generate password reset link + const resetLink = await admin.auth().generatePasswordResetLink(email); + + // Log success and return response + logger.log("admin-reset-link-success", "debug", req.user.email, null, { + request: req.body, + ioadmin: true }); + res.status(200).json({ message: "Reset link generated successfully.", resetLink }); + } catch (error) { + res.status(error.status || 500).json({ + message: error.message || "Error generating reset link.", + error + }); + } }; module.exports = { @@ -318,24 +290,6 @@ module.exports = { sendNotification, subscribe, unsubscribe, - sendwelcome, - resetlink + getWelcomeEmail, + getResetLink }; - -//Admin claims code. -// const uid = "JEqqYlsadwPEXIiyRBR55fflfko1"; - -// admin -// .auth() -// .getUser(uid) -// .then((user) => { -// console.log(user); -// admin.auth().setCustomUserClaims(uid, { -// ioadmin: true, -// "https://hasura.io/jwt/claims": { -// "x-hasura-default-role": "debug", -// "x-hasura-allowed-roles": ["admin"], -// "x-hasura-user-id": uid, -// }, -// }); -// }); diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 16c955467..e1bfbc4cc 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2853,3 +2853,21 @@ query GET_BODYSHOP_BY_MERCHANTID($merchantID: String!) { email } }`; + +exports.GET_USER_BY_EMAIL = ` +query GET_USER_BY_EMAIL($email: String!) { + users(where: {email: {_eq: $email}}) { + email + validemail + associations { + id + shopid + bodyshop { + id + convenient_company + features + timezone + } + } + } +}`; diff --git a/server/routes/adminRoutes.js b/server/routes/adminRoutes.js index a8c0e98b4..909f11344 100644 --- a/server/routes/adminRoutes.js +++ b/server/routes/adminRoutes.js @@ -2,7 +2,7 @@ const express = require("express"); const router = express.Router(); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); const { createAssociation, createShop, updateShop, updateCounter } = require("../admin/adminops"); -const { updateUser, getUser, createUser, sendwelcome, resetlink } = require("../firebase/firebase-handler"); +const { updateUser, getUser, createUser, getWelcomeEmail, getResetLink } = require("../firebase/firebase-handler"); const validateAdminMiddleware = require("../middleware/validateAdminMiddleware"); router.use(validateFirebaseIdTokenMiddleware); @@ -15,7 +15,7 @@ router.post("/updatecounter", updateCounter); router.post("/updateuser", updateUser); router.post("/getuser", getUser); router.post("/createuser", createUser); -router.post("/sendwelcome", sendwelcome); -router.post("/resetlink", resetlink); +router.post("/sendwelcome", getWelcomeEmail); +router.post("/resetlink", getResetLink); module.exports = router; From f2a896d5687ad39f758dc4165843e9bbc63dec28 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 15 Apr 2025 14:02:29 -0400 Subject: [PATCH 015/195] feature/IO-3187-Admin-Enhancements - Minor cleanup --- server/firebase/firebase-handler.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/server/firebase/firebase-handler.js b/server/firebase/firebase-handler.js index f88d352f1..86a558524 100644 --- a/server/firebase/firebase-handler.js +++ b/server/firebase/firebase-handler.js @@ -241,12 +241,13 @@ const getWelcomeEmail = async (req, res) => { ioadmin: true, emailSentTo: email }); - res.status(200).json({ message: "Welcome email sent successfully." }); + + return res.status(200).json({ message: "Welcome email sent successfully." }); } catch (error) { logger.log("admin-send-welcome-email-error", "ERROR", req.user.email, null, { error }); if (!res.headersSent) { - res.status(error.status || 500).json({ + return res.status(error.status || 500).json({ message: error.message || "Error sending welcome email.", error }); @@ -273,9 +274,10 @@ const getResetLink = async (req, res) => { request: req.body, ioadmin: true }); - res.status(200).json({ message: "Reset link generated successfully.", resetLink }); + + return res.status(200).json({ message: "Reset link generated successfully.", resetLink }); } catch (error) { - res.status(error.status || 500).json({ + return res.status(error.status || 500).json({ message: error.message || "Error generating reset link.", error }); From aa6ad109c90d315210ff8300bcd53c73e6abab35 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 15 Apr 2025 14:21:28 -0400 Subject: [PATCH 016/195] feature/IO-3187-Admin-Enhancements - Minor cleanup --- server/integrations/VSSTA/vsstaIntegrationRoute.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/integrations/VSSTA/vsstaIntegrationRoute.js b/server/integrations/VSSTA/vsstaIntegrationRoute.js index c98b689ca..f7444477a 100644 --- a/server/integrations/VSSTA/vsstaIntegrationRoute.js +++ b/server/integrations/VSSTA/vsstaIntegrationRoute.js @@ -51,8 +51,8 @@ const vsstaIntegrationRoute = async (req, res) => { }); } - const { shop_id, ro_nbr, pdf_download_link, scan_type, scan_time, technician, year, make, model, company_api_key } = - req.body; + // technician, year, make, model, is also available. + const { shop_id, ro_nbr, pdf_download_link, scan_type, scan_time, company_api_key } = req.body; // 1. Get the job record by ro_number and shop_id const jobResult = await client.request(GET_JOB_BY_RO_NUMBER_AND_SHOP_ID, { @@ -82,7 +82,7 @@ const vsstaIntegrationRoute = async (req, res) => { // 4. Generate key for S3 const timestamp = moment(scan_time).tz(job.bodyshop.timezone).format("YYYYMMDD-HHmmss"); - const fileName = `${timestamp}_VSSTA_${scan_type}_Scan_${technician}_${year}_${make}_${model}`; + const fileName = `${timestamp}_VSSTA_${scan_type}`; const s3Key = `${job.shopid}/${job.id}/${fileName.replace(/[^A-Z0-9]+/gi, "_")}.pdf`; // 5. Generate presigned URL for S3 upload From 159ee7364d744878b33e399516bf2b13568b6d7b Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Tue, 15 Apr 2025 22:08:21 -0700 Subject: [PATCH 017/195] IO-3187 Admin Enhancements add BCC Signed-off-by: Allan Carr --- server/email/sendemail.js | 3 ++- server/firebase/firebase-handler.js | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/server/email/sendemail.js b/server/email/sendemail.js index e8e6fb689..0c6ce4015 100644 --- a/server/email/sendemail.js +++ b/server/email/sendemail.js @@ -83,7 +83,7 @@ const sendServerEmail = async ({ subject, text }) => { } }; -const sendWelcomeEmail = async ({ to, resetLink, dateLine, features }) => { +const sendWelcomeEmail = async ({ to, resetLink, dateLine, features, bcc }) => { try { await mailer.sendMail({ from: InstanceManager({ @@ -91,6 +91,7 @@ const sendWelcomeEmail = async ({ to, resetLink, dateLine, features }) => { rome: `Rome Online ` }), to, + bcc, subject: InstanceManager({ imex: "Welcome to the ImEX Online platform.", rome: "Welcome to the Rome Online platform." diff --git a/server/firebase/firebase-handler.js b/server/firebase/firebase-handler.js index 86a558524..83c6082eb 100644 --- a/server/firebase/firebase-handler.js +++ b/server/firebase/firebase-handler.js @@ -198,7 +198,7 @@ const unsubscribe = async (req, res) => { }; const getWelcomeEmail = async (req, res) => { - const { authid, email } = req.body; + const { authid, email, bcc } = req.body; try { // Fetch user from Firebase @@ -232,7 +232,8 @@ const getWelcomeEmail = async (req, res) => { to: dbUser.email, resetLink, dateLine: moment().tz(dbUser.associations?.[0]?.bodyshop?.timezone).format("MM/DD/YYYY @ hh:mm a"), - features: dbUser.associations?.[0]?.bodyshop?.features + features: dbUser.associations?.[0]?.bodyshop?.features, + bcc }); // Log success and return response From b0dcd3618e8de16acf26915ad57e395156a5bd42 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Wed, 16 Apr 2025 13:00:01 -0700 Subject: [PATCH 018/195] IO-3190 Quick Intake Schedule Event Signed-off-by: Allan Carr --- .gitignore | 2 + .../schedule-event.component.jsx | 121 +++++++++++++++++- client/src/utils/AuditTrailMappings.js | 4 +- 3 files changed, 119 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 9b2ed79f9..9a2f7ecc1 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,5 @@ vitest-coverage/ *.vitest.log test-output.txt server/job/test/fixtures + +.github diff --git a/client/src/components/job-at-change/schedule-event.component.jsx b/client/src/components/job-at-change/schedule-event.component.jsx index 4facb2a19..123bc9879 100644 --- a/client/src/components/job-at-change/schedule-event.component.jsx +++ b/client/src/components/job-at-change/schedule-event.component.jsx @@ -1,5 +1,5 @@ import { AlertFilled } from "@ant-design/icons"; -import { useMutation } from "@apollo/client"; +import { useLazyQuery, useMutation } from "@apollo/client"; import { Button, Divider, Dropdown, Form, Input, Popover, Select, Space } from "antd"; import parsePhoneNumber from "libphonenumber-js"; import queryString from "query-string"; @@ -8,24 +8,30 @@ import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { createStructuredSelector } from "reselect"; +import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries"; +import { GET_JOB_BY_PK_QUICK_INTAKE, JOB_PRODUCTION_TOGGLE } from "../../graphql/jobs.queries"; +import { insertAuditTrail } from "../../redux/application/application.actions"; import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions"; import { setModalContext } from "../../redux/modals/modals.actions"; import { selectBodyshop } from "../../redux/user/user.selectors"; +import AuditTrailMapping from "../../utils/AuditTrailMappings"; import CurrencyFormatter from "../../utils/CurrencyFormatter"; +import { DateTimeFormatterFunction } from "../../utils/DateFormatter"; import dayjs from "../../utils/day"; import { GenerateDocument } from "../../utils/RenderTemplate"; import { TemplateList } from "../../utils/TemplateConstants"; import ChatOpenButton from "../chat-open-button/chat-open-button.component"; import DataLabel from "../data-label/data-label.component"; +import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component"; import OwnerNameDisplay from "../owner-name-display/owner-name-display.component"; import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component"; import ScheduleManualEvent from "../schedule-manual-event/schedule-manual-event.component"; +import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component"; import ScheduleAtChange from "./job-at-change.component"; import ScheduleEventColor from "./schedule-event.color.component"; import ScheduleEventNote from "./schedule-event.note.component"; -import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -33,7 +39,8 @@ const mapStateToProps = createStructuredSelector({ const mapDispatchToProps = (dispatch) => ({ setScheduleContext: (context) => dispatch(setModalContext({ context: context, modal: "schedule" })), openChatByPhone: (phone) => dispatch(openChatByPhone(phone)), - setMessage: (text) => dispatch(setMessage(text)) + setMessage: (text) => dispatch(setMessage(text)), + insertAuditTrail: ({ jobid, operation }) => dispatch(insertAuditTrail({ jobid, operation })) }); export function ScheduleEventComponent({ @@ -43,16 +50,36 @@ export function ScheduleEventComponent({ event, refetch, handleCancel, - setScheduleContext + setScheduleContext, + insertAuditTrail }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const history = useNavigate(); const searchParams = queryString.parse(useLocation().search); const [updateAppointment] = useMutation(UPDATE_APPOINTMENT); + const [mutationUpdateJob] = useMutation(JOB_PRODUCTION_TOGGLE); const [title, setTitle] = useState(event.title); const { socket } = useSocket(); const notification = useNotification(); + const [form] = Form.useForm(); + const [popOverVisible, setPopOverVisible] = useState(false); + + const [getJobDetails] = useLazyQuery(GET_JOB_BY_PK_QUICK_INTAKE, { + variables: { id: event.job.id }, + onCompleted: (data) => { + if (data?.jobs_by_pk) { + form.setFieldsValue({ + actual_in: data.jobs_by_pk.actual_in ? data.jobs_by_pk.actual_in : dayjs(), + scheduled_completion: data.jobs_by_pk.scheduled_completion, + actual_completion: data.jobs_by_pk.actual_completion, + scheduled_delivery: data.jobs_by_pk.scheduled_delivery, + actual_delivery: data.jobs_by_pk.actual_delivery + }); + } + }, + fetchPolicy: "network-only" + }); const blockContent = ( @@ -89,6 +116,74 @@ export function ScheduleEventComponent({ ); + const handleConvert = async (values) => { + const res = await mutationUpdateJob({ + variables: { + jobId: event.job.id, + job: { + ...values, + status: bodyshop.md_ro_statuses.default_arrived, + inproduction: true + } + } + }); + + if (!res.errors) { + notification["success"]({ + message: t("jobs.successes.converted") + }); + insertAuditTrail({ + jobid: event.job.id, + operation: AuditTrailMapping.jobintake( + res.data.update_jobs.returning[0].status, + DateTimeFormatterFunction(values.scheduled_completion) + ) + }); + setPopOverVisible(false); + refetch(); + } + }; + + const popMenu = ( +
e.stopPropagation()}> +
+ + + + + + + + + + + + + +
+
+ ); + const popoverContent = (
{!event.isintake ? ( @@ -294,7 +389,7 @@ export function ScheduleEventComponent({ ) : ( )} - {event.isintake ? ( + {event.isintake && HasFeatureAccess({ featureName: "checklist", bodyshop }) ? ( - ) : null} + ) : ( + { + getJobDetails(); + e.stopPropagation(); + }} + getPopupContainer={(trigger) => trigger.parentNode} + trigger="click" + > + + + )}
); diff --git a/client/src/utils/AuditTrailMappings.js b/client/src/utils/AuditTrailMappings.js index f2680d9d8..ea1494fec 100644 --- a/client/src/utils/AuditTrailMappings.js +++ b/client/src/utils/AuditTrailMappings.js @@ -15,8 +15,8 @@ const AuditTrailMapping = { jobchecklist: (type, inproduction, status) => i18n.t("audit_trail.messages.jobchecklist", { type, inproduction, status }), jobconverted: (ro_number) => i18n.t("audit_trail.messages.jobconverted", { ro_number }), - jobintake: (status, email, scheduled_completion) => - i18n.t("audit_trail.messages.jobintake", { status, email, scheduled_completion }), + jobintake: (status, scheduled_completion) => + i18n.t("audit_trail.messages.jobintake", { status, scheduled_completion }), jobdelivery: (status, email, actual_completion) => i18n.t("audit_trail.messages.jobdelivery", { status, email, actual_completion }), jobexported: () => i18n.t("audit_trail.messages.jobexported"), From 19e42ef3971ff3cc9233109cd21ee57b1e9a200d Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Wed, 16 Apr 2025 17:52:22 -0700 Subject: [PATCH 019/195] IO-3210 Podium Datapump Signed-off-by: Allan Carr --- hasura/metadata/tables.yaml | 1 + .../down.sql | 4 + .../up.sql | 2 + server/data/chatter.js | 1 - server/data/data.js | 3 +- server/data/podium.js | 211 ++++++++++++++++++ server/graphql-client/queries.js | 31 +++ server/routes/dataRoutes.js | 3 +- 8 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 hasura/migrations/1744847142775_alter_table_public_bodyshops_add_column_podiumid/down.sql create mode 100644 hasura/migrations/1744847142775_alter_table_public_bodyshops_add_column_podiumid/up.sql create mode 100644 server/data/podium.js diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index e783e3935..0e8cbb888 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -1005,6 +1005,7 @@ - pbs_configuration - pbs_serialnumber - phone + - podiumid - prodtargethrs - production_config - region_config diff --git a/hasura/migrations/1744847142775_alter_table_public_bodyshops_add_column_podiumid/down.sql b/hasura/migrations/1744847142775_alter_table_public_bodyshops_add_column_podiumid/down.sql new file mode 100644 index 000000000..dd841c42a --- /dev/null +++ b/hasura/migrations/1744847142775_alter_table_public_bodyshops_add_column_podiumid/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"."bodyshops" add column "podiumid" text +-- null; diff --git a/hasura/migrations/1744847142775_alter_table_public_bodyshops_add_column_podiumid/up.sql b/hasura/migrations/1744847142775_alter_table_public_bodyshops_add_column_podiumid/up.sql new file mode 100644 index 000000000..de9752746 --- /dev/null +++ b/hasura/migrations/1744847142775_alter_table_public_bodyshops_add_column_podiumid/up.sql @@ -0,0 +1,2 @@ +alter table "public"."bodyshops" add column "podiumid" text + null; diff --git a/server/data/chatter.js b/server/data/chatter.js index 993707c41..3f84988ca 100644 --- a/server/data/chatter.js +++ b/server/data/chatter.js @@ -2,7 +2,6 @@ const path = require("path"); const queries = require("../graphql-client/queries"); const moment = require("moment-timezone"); const converter = require("json-2-csv"); -const _ = require("lodash"); const logger = require("../utils/logger"); const fs = require("fs"); const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager"); diff --git a/server/data/data.js b/server/data/data.js index bc79ef9a3..efd662c13 100644 --- a/server/data/data.js +++ b/server/data/data.js @@ -3,4 +3,5 @@ exports.autohouse = require("./autohouse").default; exports.chatter = require("./chatter").default; exports.claimscorp = require("./claimscorp").default; exports.kaizen = require("./kaizen").default; -exports.usageReport = require("./usageReport").default; \ No newline at end of file +exports.usageReport = require("./usageReport").default; +exports.podium = require("./podium").default; \ No newline at end of file diff --git a/server/data/podium.js b/server/data/podium.js new file mode 100644 index 000000000..69dfd3226 --- /dev/null +++ b/server/data/podium.js @@ -0,0 +1,211 @@ +const path = require("path"); +const queries = require("../graphql-client/queries"); +const moment = require("moment-timezone"); +const converter = require("json-2-csv"); +const logger = require("../utils/logger"); +const fs = require("fs"); +require("dotenv").config({ + path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) +}); +let Client = require("ssh2-sftp-client"); + +const client = require("../graphql-client/graphql-client").client; +const { sendServerEmail } = require("../email/sendemail"); + +const ftpSetup = { + host: process.env.PODIUM_HOST, + port: process.env.PODIUM_PORT, + username: process.env.PODIUM_USER, + password: process.env.PODIUM_PASSWORD, + debug: + process.env.NODE_ENV !== "production" + ? (message, ...data) => logger.log(message, "DEBUG", "api", null, data) + : () => {}, + algorithms: { + serverHostKey: ["ssh-rsa", "ssh-dss", "rsa-sha2-256", "rsa-sha2-512", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"] + } +}; + +exports.default = async (req, res) => { + // Only process if in production environment. + if (process.env.NODE_ENV !== "production") { + res.sendStatus(403); + return; + } + // Only process if the appropriate token is provided. + if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) { + res.sendStatus(401); + return; + } + + // Send immediate response and continue processing. + res.status(202).json({ + success: true, + message: "Processing request ...", + timestamp: new Date().toISOString() + }); + + try { + logger.log("podium-start", "DEBUG", "api", null, null); + const allCSVResults = []; + const allErrors = []; + + const { bodyshops } = await client.request(queries.GET_PODIUM_SHOPS); //Query for the List of Bodyshop Clients. + const specificShopIds = req.body.bodyshopIds; // ['uuid]; + const { start, end, skipUpload } = req.body; //YYYY-MM-DD + + const shopsToProcess = + specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops; + logger.log("podium-shopsToProcess-generated", "DEBUG", "api", null, null); + + if (shopsToProcess.length === 0) { + logger.log("podium-shopsToProcess-empty", "DEBUG", "api", null, null); + return; + } + + await processShopData(shopsToProcess, start, end, skipUpload, allCSVResults, allErrors); + + await sendServerEmail({ + subject: `Podium Report ${moment().format("MM-DD-YY")}`, + text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify( + allCSVResults.map((x) => ({ + imexshopid: x.imexshopid, + filename: x.filename, + count: x.count, + result: x.result + })), + null, + 2 + )}` + }); + + logger.log("podium-end", "DEBUG", "api", null, null); + } catch (error) { + logger.log("podium-error", "ERROR", "api", null, { error: error.message, stack: error.stack }); + } +}; + +async function processShopData(shopsToProcess, start, end, skipUpload, allCSVResults, allErrors) { + for (const bodyshop of shopsToProcess) { + const erroredJobs = []; + try { + logger.log("podium-start-shop-extract", "DEBUG", "api", bodyshop.id, { + shopname: bodyshop.shopname + }); + + const { jobs, bodyshops_by_pk } = await client.request(queries.PODIUM_QUERY, { + bodyshopid: bodyshop.id, + start: start ? moment(start).startOf("day") : moment().subtract(2, "days").startOf("day"), + ...(end && { end: moment(end).endOf("day") }) + }); + + const podiumObject = jobs.map((j) => { + return { + "Podium Account ID": bodyshops_by_pk.podiumid, + "First Name": j.ownr_co_nm ? null : j.ownr_fn, + "Last Name": j.ownr_co_nm ? j.ownr_co_nm : j.ownr_ln, + "SMS Number": null, + "Phone 1": j.ownr_ph1, + "Phone 2": j.ownr_ph2, + Email: j.ownr_ea, + "Delivered Date": + (j.actual_delivery && moment(j.actual_delivery).tz(bodyshop.timezone).format("MM/DD/YYYY")) || "" + }; + }); + + if (erroredJobs.length > 0) { + logger.log("podium-failed-jobs", "ERROR", "api", bodyshop.id, { + count: erroredJobs.length, + jobs: JSON.stringify(erroredJobs.map((j) => j.job.ro_number)) + }); + } + + const csvObj = { + bodyshopid: bodyshop.id, + imexshopid: bodyshop.imexshopid, + csv: converter.json2csv(podiumObject, { emptyFieldValue: "" }), + filename: `${bodyshop.podiumid}-${moment().format("YYYYMMDDTHHMMss")}.csv`, + count: podiumObject.length + }; + + if (skipUpload) { + fs.writeFileSync(`./logs/${csvObj.filename}`, csvObj.csv); + } else { + await uploadViaSFTP(csvObj); + } + + allCSVResults.push({ + bodyshopid: bodyshop.id, + imexshopid: bodyshop.imexshopid, + podiumid: bodyshop.podiumid, + count: csvObj.count, + filename: csvObj.filename, + result: csvObj.result + }); + + logger.log("podium-end-shop-extract", "DEBUG", "api", bodyshop.id, { + shopname: bodyshop.shopname + }); + } catch (error) { + //Error at the shop level. + logger.log("podium-error-shop", "ERROR", "api", bodyshop.id, { error: error.message, stack: error.stack }); + + allErrors.push({ + bodyshopid: bodyshop.id, + imexshopid: bodyshop.imexshopid, + podiumid: bodyshop.podiumid, + fatal: true, + errors: [error.toString()] + }); + } finally { + allErrors.push({ + bodyshopid: bodyshop.id, + imexshopid: bodyshop.imexshopid, + podiumid: bodyshop.podiumid, + errors: erroredJobs.map((ej) => ({ + ro_number: ej.job?.ro_number, + jobid: ej.job?.id, + error: ej.error + })) + }); + } + } +} + +async function uploadViaSFTP(csvObj) { + const sftp = new Client(); + sftp.on("error", (errors) => + logger.log("podium-sftp-connection-error", "ERROR", "api", csvObj.bodyshopid, { + error: errors.message, + stack: errors.stack + }) + ); + try { + //Connect to the FTP and upload all. + await sftp.connect(ftpSetup); + + try { + csvObj.result = await sftp.put(Buffer.from(csvObj.xml), `${csvObj.filename}`); + logger.log("podium-sftp-upload", "DEBUG", "api", csvObj.bodyshopid, { + imexshopid: csvObj.imexshopid, + filename: csvObj.filename, + result: csvObj.result + }); + } catch (error) { + logger.log("podium-sftp-upload-error", "ERROR", "api", csvObj.bodyshopid, { + filename: csvObj.filename, + error: error.message, + stack: error.stack + }); + throw error; + } + } catch (error) { + logger.log("podium-sftp-error", "ERROR", "api", csvObj.bodyshopid, { + error: error.message, + stack: error.stack + }); + throw error; + } finally { + sftp.end(); + } +} diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 16c955467..9334c9b72 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -1323,6 +1323,27 @@ exports.KAIZEN_QUERY = `query KAIZEN_EXPORT($start: timestamptz, $bodyshopid: uu } }`; +exports.PODIUM_QUERY = `query PODIUM_EXPORT($start: timestamptz, $bodyshopid: uuid!, $end: timestamptz) { + bodyshops_by_pk(id: $bodyshopid){ + id + shopname + podiumid + timezone + } + jobs(where: {_and: [{converted: {_eq: true}}, {actual_delivery: {_gt: $start}}, {actual_delivery: {_lte: $end}}, {shopid: {_eq: $bodyshopid}}, {_or: [{ownr_ph1: {_is_null: false}}, {ownr_ea: {_is_null: false}}]}]}) { + actual_delivery + id + created_at + ro_number + ownr_fn + ownr_ln + ownr_co_nm + ownr_ph1 + ownr_ph2 + ownr_ea + } +}`; + exports.UPDATE_JOB = ` mutation UPDATE_JOB($jobId: uuid!, $job: jobs_set_input!) { update_jobs(where: { id: { _eq: $jobId } }, _set: $job) { @@ -1848,6 +1869,16 @@ exports.GET_KAIZEN_SHOPS = `query GET_KAIZEN_SHOPS($imexshopid: [String]) { } }`; +exports.GET_PODIUM_SHOPS = `query GET_PODIUM_SHOPS { + bodyshops(where: {podiumid: {_is_null: false}, _or: {podiumid: {_neq: ""}}}){ + id + shopname + podiumid + imexshopid + timezone + } +}`; + exports.DELETE_ALL_DMS_VEHICLES = `mutation DELETE_ALL_DMS_VEHICLES{ delete_dms_vehicles(where: {}) { affected_rows diff --git a/server/routes/dataRoutes.js b/server/routes/dataRoutes.js index 788574074..f8212c36d 100644 --- a/server/routes/dataRoutes.js +++ b/server/routes/dataRoutes.js @@ -1,11 +1,12 @@ const express = require("express"); const router = express.Router(); -const { autohouse, claimscorp, chatter, kaizen, usageReport } = require("../data/data"); +const { autohouse, claimscorp, chatter, kaizen, usageReport, podium } = require("../data/data"); router.post("/ah", autohouse); router.post("/cc", claimscorp); router.post("/chatter", chatter); router.post("/kaizen", kaizen); router.post("/usagereport", usageReport); +router.post("/podium", podium); module.exports = router; From 8840ffc9ba1ab295bebce83dde5827f64c5f1a69 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Thu, 17 Apr 2025 10:03:08 -0700 Subject: [PATCH 020/195] IO-3210 Product Fruits Insurance Company Add Button ID Signed-off-by: Allan Carr --- client/src/components/shop-info/shop-info.general.component.jsx | 1 + 1 file changed, 1 insertion(+) 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 3d80a93ad..58bacb2aa 100644 --- a/client/src/components/shop-info/shop-info.general.component.jsx +++ b/client/src/components/shop-info/shop-info.general.component.jsx @@ -906,6 +906,7 @@ export function ShopInfoGeneral({ form, bodyshop }) { add(); }} style={{ width: "100%" }} + id="insurancecos-add-button" > {t("general.actions.add")} From 37d4c0a40fe9b556e1555c6773ca41f3bcacb4d4 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Thu, 17 Apr 2025 12:42:19 -0700 Subject: [PATCH 021/195] IO-3210 Podium Datapump CRON Trigger Signed-off-by: Allan Carr --- hasura/metadata/cron_triggers.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/hasura/metadata/cron_triggers.yaml b/hasura/metadata/cron_triggers.yaml index 2b504e492..534560869 100644 --- a/hasura/metadata/cron_triggers.yaml +++ b/hasura/metadata/cron_triggers.yaml @@ -31,6 +31,15 @@ headers: - name: x-imex-auth value_from_env: DATAPUMP_AUTH +- name: Podium Data Pump + webhook: '{{HASURA_API_URL}}/data/podium' + schedule: 15 5 * * * + include_in_metadata: true + payload: {} + headers: + - name: x-imex-auth + value_from_env: DATAPUMP_AUTH + comment: "" - name: Rome Usage Report webhook: '{{HASURA_API_URL}}/data/usagereport' schedule: 0 12 * * 5 From 6a9e36ea4d38035179bff4b7b2261b304bb90944 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Thu, 17 Apr 2025 16:20:34 -0700 Subject: [PATCH 022/195] IO-3200 Extended Crisp Segments for BASIC/LITE Signed-off-by: Allan Carr --- client/src/redux/user/user.sagas.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/client/src/redux/user/user.sagas.js b/client/src/redux/user/user.sagas.js index 35f3d9d97..37b30d916 100644 --- a/client/src/redux/user/user.sagas.js +++ b/client/src/redux/user/user.sagas.js @@ -1,7 +1,4 @@ import FingerprintJS from "@fingerprintjs/fingerprintjs"; -import * as Sentry from "@sentry/browser"; -import { notification } from "antd"; -import axios from "axios"; import { setUserId, setUserProperties } from "@firebase/analytics"; import { checkActionCode, @@ -12,6 +9,9 @@ import { } from "@firebase/auth"; import { arrayUnion, doc, getDoc, setDoc, updateDoc } from "@firebase/firestore"; import { getToken } from "@firebase/messaging"; +import * as Sentry from "@sentry/browser"; +import { notification } from "antd"; +import axios from "axios"; import i18next from "i18next"; import LogRocket from "logrocket"; import { all, call, delay, put, select, takeLatest } from "redux-saga/effects"; @@ -351,7 +351,14 @@ export function* SetAuthLevelFromShopDetails({ payload }) { }); payload.features?.allAccess === true ? window.$crisp.push(["set", "session:segments", [["allAccess"]]]) - : window.$crisp.push(["set", "session:segments", [["basic"]]]); + : (() => { + const featureKeys = Object.keys(payload.features).filter( + (key) => + payload.features[key] === true || + (typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key]))) + ); + window.$crisp.push(["set", "session:segments", [["basic", ...featureKeys]]]); + })(); } catch (error) { console.warn("Couldnt find $crisp.", error.message); } From c21cc8d6b976783705739ff66798f5ed363fbf95 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Mon, 21 Apr 2025 12:27:33 -0700 Subject: [PATCH 023/195] IO-3164 Schedule Completion Business Days Signed-off-by: Allan Carr --- .../job-at-change/schedule-event.component.jsx | 13 +++++++++---- ...detail-header-actions.toggle-production.jsx | 14 ++++++-------- .../schedule-job-modal.component.jsx | 18 ++++++++++-------- .../shop-info.scheduling.component.jsx | 14 ++++++++++---- client/src/translations/en_us/common.json | 3 ++- client/src/translations/es/common.json | 3 ++- client/src/translations/fr/common.json | 3 ++- 7 files changed, 41 insertions(+), 27 deletions(-) diff --git a/client/src/components/job-at-change/schedule-event.component.jsx b/client/src/components/job-at-change/schedule-event.component.jsx index 123bc9879..f37290fbc 100644 --- a/client/src/components/job-at-change/schedule-event.component.jsx +++ b/client/src/components/job-at-change/schedule-event.component.jsx @@ -69,12 +69,17 @@ export function ScheduleEventComponent({ variables: { id: event.job.id }, onCompleted: (data) => { if (data?.jobs_by_pk) { + const totalHours = + (data.jobs_by_pk.labhrs?.aggregate?.sum?.mod_lb_hrs || 0) + + (data.jobs_by_pk.larhrs?.aggregate?.sum?.mod_lb_hrs || 0); form.setFieldsValue({ actual_in: data.jobs_by_pk.actual_in ? data.jobs_by_pk.actual_in : dayjs(), - scheduled_completion: data.jobs_by_pk.scheduled_completion, - actual_completion: data.jobs_by_pk.actual_completion, - scheduled_delivery: data.jobs_by_pk.scheduled_delivery, - actual_delivery: data.jobs_by_pk.actual_delivery + scheduled_completion: data.jobs_by_pk.scheduled_completion + ? data.jobs_by_pk.scheduled_completion + : totalHours && bodyshop.ss_configuration.nobusinessdays + ? dayjs().businessDaysAdd(totalHours / (bodyshop.target_touchtime || 1), "day") + : dayjs().add(totalHours / (bodyshop.target_touchtime || 1), "day"), + scheduled_delivery: data.jobs_by_pk.scheduled_delivery }); } }, diff --git a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.toggle-production.jsx b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.toggle-production.jsx index 3b6d8ec1a..c14099f89 100644 --- a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.toggle-production.jsx +++ b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.toggle-production.jsx @@ -44,18 +44,16 @@ export function JobsDetailHeaderActionsToggleProduction({ variables: { id: job.id }, onCompleted: (data) => { if (data?.jobs_by_pk) { + const totalHours = + (data.jobs_by_pk.labhrs?.aggregate?.sum?.mod_lb_hrs || 0) + + (data.jobs_by_pk.larhrs?.aggregate?.sum?.mod_lb_hrs || 0); form.setFieldsValue({ actual_in: data.jobs_by_pk.actual_in ? data.jobs_by_pk.actual_in : dayjs(), scheduled_completion: data.jobs_by_pk.scheduled_completion ? data.jobs_by_pk.scheduled_completion - : data.jobs_by_pk.labhrs && - data.jobs_by_pk.larhrs && - dayjs().businessDaysAdd( - (data.jobs_by_pk.labhrs.aggregate.sum.mod_lb_hrs || - 0 + data.jobs_by_pk.larhrs.aggregate.sum.mod_lb_hrs || - 0) / bodyshop.target_touchtime, - "day" - ), + : totalHours && bodyshop.ss_configuration.nobusinessdays + ? dayjs().businessDaysAdd(totalHours / (bodyshop.target_touchtime || 1), "day") + : dayjs().add(totalHours / (bodyshop.target_touchtime || 1), "day"), actual_completion: data.jobs_by_pk.actual_completion, scheduled_delivery: data.jobs_by_pk.scheduled_delivery, actual_delivery: data.jobs_by_pk.actual_delivery diff --git a/client/src/components/schedule-job-modal/schedule-job-modal.component.jsx b/client/src/components/schedule-job-modal/schedule-job-modal.component.jsx index c55163479..18890e507 100644 --- a/client/src/components/schedule-job-modal/schedule-job-modal.component.jsx +++ b/client/src/components/schedule-job-modal/schedule-job-modal.component.jsx @@ -1,6 +1,6 @@ import { Button, Col, Form, Input, Row, Select, Space, Switch, Typography } from "antd"; import axios from "axios"; -import React, { useState } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; @@ -8,16 +8,16 @@ import { calculateScheduleLoad } from "../../redux/application/application.actio import { selectBodyshop } from "../../redux/user/user.selectors"; import { DateFormatter } from "../../utils/DateFormatter"; import dayjs from "../../utils/day"; +import BlurWrapper from "../feature-wrapper/blur-wrapper.component"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component"; import EmailInput from "../form-items-formatted/email-form-item.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; +import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component"; import ScheduleDayViewContainer from "../schedule-day-view/schedule-day-view.container"; import ScheduleExistingAppointmentsList from "../schedule-existing-appointments-list/schedule-existing-appointments-list.component"; -import "./schedule-job-modal.scss"; -import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component"; -import BlurWrapper from "../feature-wrapper/blur-wrapper.component"; import UpsellComponent, { upsellEnum } from "../upsell/upsell.component"; +import "./schedule-job-modal.scss"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -60,10 +60,12 @@ export function ScheduleJobModalComponent({ const totalHours = lbrHrsData.jobs_by_pk.labhrs.aggregate.sum.mod_lb_hrs + lbrHrsData.jobs_by_pk.larhrs.aggregate.sum.mod_lb_hrs; - if (values.start && !values.scheduled_completion) - form.setFieldsValue({ - scheduled_completion: dayjs(values.start).businessDaysAdd(totalHours / bodyshop.target_touchtime, "day") - }); + if (values.start && !values.scheduled_completion) { + const addDays = bodyshop.ss_configuration.nobusinessdays + ? dayjs(values.start).add(totalHours / (bodyshop.target_touchtime || 1), "day") + : dayjs(values.start).businessDaysAdd(totalHours / (bodyshop.target_touchtime || 1), "day"); + form.setFieldsValue({ scheduled_completion: addDays }); + } } }; diff --git a/client/src/components/shop-info/shop-info.scheduling.component.jsx b/client/src/components/shop-info/shop-info.scheduling.component.jsx index 36fd0e6d1..4c88b5704 100644 --- a/client/src/components/shop-info/shop-info.scheduling.component.jsx +++ b/client/src/components/shop-info/shop-info.scheduling.component.jsx @@ -1,16 +1,15 @@ import { DeleteFilled } from "@ant-design/icons"; import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch, TimePicker } from "antd"; -import React from "react"; import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { selectBodyshop } from "../../redux/user/user.selectors"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; import ColorpickerFormItemComponent from "../form-items-formatted/colorpicker-form-item.component"; import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import { ColorPicker } from "./shop-info.rostatus.component"; -import { connect } from "react-redux"; -import { createStructuredSelector } from "reselect"; -import { selectBodyshop } from "../../redux/user/user.selectors"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop }); @@ -78,6 +77,13 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) { > + + + Date: Wed, 23 Apr 2025 09:51:44 -0700 Subject: [PATCH 024/195] IO-3215 Employee Assignment Timeticket Modal Signed-off-by: Allan Carr --- .../time-ticket-modal.component.jsx | 13 ++++++---- .../time-ticket-modal.container.jsx | 9 +++---- client/src/graphql/jobs-lines.queries.js | 24 +++++++++++++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx b/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx index 7f2a1df72..84dbe8caa 100644 --- a/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx +++ b/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx @@ -1,7 +1,6 @@ import { useLazyQuery } from "@apollo/client"; import { useSplitTreatments } from "@splitsoftware/splitio-react"; -import { Form, Input, InputNumber, Select, Switch } from "antd"; -import React from "react"; +import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; @@ -19,6 +18,7 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component"; import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component"; import TimeTicketList from "../time-ticket-list/time-ticket-list.component"; +import JobEmployeeAssignmentsContainer from "./../job-employee-assignments/job-employee-assignments.container"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -319,10 +319,15 @@ export function TimeTicketModalComponent({ } export function LaborAllocationContainer({ jobid, loading, lineTicketData, hideTimeTickets = false }) { + const { t } = useTranslation(); if (loading) return ; if (!lineTicketData) return null; + if (!jobid) return null; return ( -
+ + + + )} -
+ ); } diff --git a/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx b/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx index 5e0393e10..bfbdc77cf 100644 --- a/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx +++ b/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx @@ -2,10 +2,11 @@ import { PageHeader } from "@ant-design/pro-layout"; import { useMutation, useQuery } from "@apollo/client"; import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { Button, Form, Modal, Space } from "antd"; -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; +import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { QUERY_ACTIVE_EMPLOYEES } from "../../graphql/employees.queries"; import { INSERT_NEW_TIME_TICKET, UPDATE_TIME_TICKET } from "../../graphql/timetickets.queries"; import { toggleModalVisible } from "../../redux/modals/modals.actions"; @@ -14,7 +15,6 @@ import { selectBodyshop } from "../../redux/user/user.selectors"; import dayjs from "../../utils/day"; import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component"; import TimeTicketModalComponent from "./time-ticket-modal.component"; -import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; const mapStateToProps = createStructuredSelector({ timeTicketModal: selectTimeTicket, @@ -81,7 +81,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, } }; - const handleMutationSuccess = (response) => { + const handleMutationSuccess = () => { notification["success"]({ message: t("timetickets.successes.created") }); @@ -123,7 +123,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, if (timeTicketModal.open) form.resetFields(); }, [timeTicketModal.open, form]); - const handleFieldsChange = (changedFields, allFields) => { + const handleFieldsChange = (changedFields) => { if (!!changedFields.employeeid && !!EmployeeAutoCompleteData) { const emps = EmployeeAutoCompleteData.employees.filter((e) => e.id === changedFields.employeeid); form.setFieldsValue({ @@ -182,6 +182,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, } destroyOnClose + id="time-ticket-modal" >
Date: Wed, 23 Apr 2025 12:04:29 -0700 Subject: [PATCH 025/195] IO-3213 Hit and Run Toggle Signed-off-by: Allan Carr --- .../jobs-detail-general.component.jsx | 4 +++- .../jobs-detail-header.component.jsx | 10 +++++++++- client/src/graphql/jobs.queries.js | 1 + client/src/translations/en_us/common.json | 1 + client/src/translations/es/common.json | 1 + client/src/translations/fr/common.json | 1 + hasura/metadata/tables.yaml | 3 +++ .../down.sql | 4 ++++ .../up.sql | 2 ++ 9 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 hasura/migrations/1745427508374_alter_table_public_jobs_add_column_hit_and_run/down.sql create mode 100644 hasura/migrations/1745427508374_alter_table_public_jobs_add_column_hit_and_run/up.sql diff --git a/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx b/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx index f36269c83..b8e1ba7aa 100644 --- a/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx +++ b/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx @@ -1,5 +1,4 @@ import { Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd"; -import React from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; @@ -188,6 +187,9 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) { + + +