diff --git a/client/src/pages/dms/dms.container.jsx b/client/src/pages/dms/dms.container.jsx index cba7a4b84..00d3fa586 100644 --- a/client/src/pages/dms/dms.container.jsx +++ b/client/src/pages/dms/dms.container.jsx @@ -79,6 +79,42 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse }); const logsRef = useRef(null); + const handleExportFailed = (payload = {}) => { + const { title, friendlyMessage, error, severity, errorCode, vendorStatusCode } = payload; + + // Prefer server-provided nice text; otherwise generic fallback + const msg = + friendlyMessage || + error || + t("dms.errors.exportfailedgeneric", "We couldn't complete the export. Please try again."); + + // Title defaults by DMS + const vendorTitle = title || (dms === "rr" ? "Reynolds" : "DMS"); + + // Severity: warn for known soft-stops like 507, else error + const sev = + severity || (vendorStatusCode === 507 || (errorCode || "").includes("MAX_OPEN_ROS") ? "warning" : "error"); + + const notifyKind = sev === "warning" && typeof notification.warning === "function" ? "warning" : "error"; + + notification[notifyKind]({ + message: vendorTitle, + description: msg, + duration: 10 + }); + + // Mirror to the on-screen log card + setLogs((prev) => [ + ...prev, + { + timestamp: new Date(), + level: (sev || "error").toUpperCase(), + message: `${vendorTitle}: ${msg}`, + meta: { errorCode, vendorStatusCode, raw: payload } + } + ]); + }; + useEffect(() => { document.title = t("titles.dms", { app: InstanceRenderManager({ @@ -134,17 +170,21 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse // RR channels (over wss) wsssocket.on("rr-log-event", handleLogEvent); - wsssocket.on("RR:LOG", handleLogEvent); - wsssocket.on("export-success", handleExportSuccess); wsssocket.on("rr-export-job:result", handleRrExportResult); + wsssocket.on("export-success", handleExportSuccess); + wsssocket.on("export-failed", handleExportFailed); + return () => { wsssocket.off("connect", handleConnect); wsssocket.off("reconnect", handleReconnect); wsssocket.off("connect_error", handleConnectError); + wsssocket.off("rr-log-event", handleLogEvent); - wsssocket.off("export-success", handleExportSuccess); wsssocket.off("rr-export-job:result", handleRrExportResult); + + wsssocket.off("export-success", handleExportSuccess); + wsssocket.off("export-failed", handleExportFailed); }; } @@ -166,13 +206,17 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse // Fortellis logs (wss) wsssocket.on("fortellis-log-event", handleLogEvent); wsssocket.on("export-success", handleExportSuccess); + wsssocket.on("export-failed", handleExportFailed); return () => { wsssocket.off("fortellis-log-event", handleLogEvent); wsssocket.off("export-success", handleExportSuccess); + wsssocket.off("export-failed", handleExportFailed); }; } else { // CDK/PBS via legacy /ws socket + socket.on("export-failed", handleExportFailed); + socket.on("connect", () => socket.emit("set-log-level", logLevel)); socket.on("reconnect", () => { setLogs((prev) => [ diff --git a/server/rr/rr-errors.js b/server/rr/rr-errors.js new file mode 100644 index 000000000..8cf38308c --- /dev/null +++ b/server/rr/rr-errors.js @@ -0,0 +1,48 @@ +// Map RR vendor/status failures into user-friendly messages the FE can show. + +function parseVendorStatusCode(err) { + // Prefer explicit numeric props when available + const codeProp = err?.code ?? err?.statusCode ?? err?.meta?.status?.StatusCode ?? err?.status?.StatusCode; + const num = Number(codeProp); + if (!Number.isNaN(num) && num > 0) return num; + + // Fallback: parse from message text (e.g., "... 507 CANNOT EXCEED ...") + const m = String(err?.message || "").match(/\b(\d{3})\b/); + return m ? Number(m[1]) : null; +} + +/** + * Classify RR vendor errors into a small set of stable codes/messages for the FE. + * @param {any} err + * @returns {{vendorStatusCode:number|null, errorCode:string, title:string, friendlyMessage:string, severity:'info'|'warning'|'error', canRetry:boolean}} + */ +function classifyRRVendorError(err) { + const code = parseVendorStatusCode(err); + const rawMsg = String(err?.meta?.status?.Message || err?.status?.Message || err?.message || "").toUpperCase(); + + // Open RO cap exceeded + if (code === 507 || /MAXIMUM NUMBER OF ROS/.test(rawMsg)) { + return { + vendorStatusCode: 507, + errorCode: "RR_MAX_OPEN_ROS", + title: "Customer Open RO limit reached", + friendlyMessage: + "Reynolds has reached the maximum number of open Repair Orders for this Customer. Close or finalize an RO in Reynolds, then try again.", + severity: "warning", + canRetry: true + }; + } + + // Default fallback + return { + vendorStatusCode: code ?? null, + errorCode: "RR_VENDOR_ERROR", + title: "Reynolds request failed", + friendlyMessage: + "We couldn’t complete the request with Reynolds. Please try again or contact support if this persists.", + severity: "error", + canRetry: true + }; +} + +module.exports = { classifyRRVendorError }; diff --git a/server/web-sockets/rr-register-socket-events.js b/server/rr/rr-register-socket-events.js similarity index 88% rename from server/web-sockets/rr-register-socket-events.js rename to server/rr/rr-register-socket-events.js index d6bd27680..b8af6ae8b 100644 --- a/server/web-sockets/rr-register-socket-events.js +++ b/server/rr/rr-register-socket-events.js @@ -1,13 +1,12 @@ -// File: server/web-sockets/rr-register-socket-events.js -// RR events aligned to Fortellis flow with Fortellis-style logging via CreateRRLogEvent - -const CreateRRLogEvent = require("../rr/rr-logger-event"); -const { rrCombinedSearch, rrGetAdvisors, buildClientAndOpts } = require("../rr/rr-lookup"); -const { QueryJobData } = require("../rr/rr-job-helpers"); -const { exportJobToRR } = require("../rr/rr-job-export"); +const CreateRRLogEvent = require("./rr-logger-event"); +const { rrCombinedSearch, rrGetAdvisors, buildClientAndOpts } = require("./rr-lookup"); +const { QueryJobData } = require("./rr-job-helpers"); +const { exportJobToRR } = require("./rr-job-export"); const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default; -const { createRRCustomer } = require("../rr/rr-customers"); -const { ensureRRServiceVehicle } = require("../rr/rr-service-vehicles"); +const { createRRCustomer } = require("./rr-customers"); +const { ensureRRServiceVehicle } = require("./rr-service-vehicles"); +const { classifyRRVendorError } = require("./rr-errors"); + const { makeVehicleSearchPayloadFromJob, ownersFromVinBlocks, @@ -16,24 +15,44 @@ const { normalizeCustomerCandidates, defaultRRTTL, RRCacheEnums -} = require("../rr/rr-utils"); +} = require("./rr-utils"); const { GraphQLClient } = require("graphql-request"); const queries = require("../graphql-client/queries"); -// 1 week TTL for advisors cache +/** + * Advisors cache TTL (7 days) + * @type {number} + */ const ADVISORS_CACHE_TTL = 7 * 24 * 60 * 60; // seconds -// ---------------- utils ---------------- +/** + * Resolve job ID from various shapes + * @param explicit + * @param payload + * @param job + * @returns {*|null} + */ function resolveJobId(explicit, payload, job) { return explicit || payload?.jobId || payload?.jobid || job?.id || job?.jobId || job?.jobid || null; } +/** + * Resolve VIN from tx/job shapes + * @param tx + * @param job + * @returns {*|null} + */ function resolveVin({ tx, job }) { // Prefer cached tx vin (if we made one), then common job shapes (v_vin for our schema) - return tx?.jobData?.vin || job?.v_vin || job?.vehicle?.vin || job?.vin || job?.vehicleVin || null; + return tx?.jobData?.vin || job?.v_vin || null; } +/** + * Sort vehicle owners first in the list, preserving original order otherwise. + * @param list + * @returns {*} + */ function sortVehicleOwnerFirst(list) { return list .map((v, i) => ({ v, i })) @@ -73,6 +92,12 @@ function mergeByCustNo(items = []) { return Array.from(byId.values()); } +/** + * Get session data or socket fallback + * @param redisHelpers + * @param socket + * @returns {Promise<{bodyshopId: *, email: *, sess: null}>} + */ async function getSessionOrSocket(redisHelpers, socket) { let sess = null; try { @@ -86,6 +111,12 @@ async function getSessionOrSocket(redisHelpers, socket) { return { bodyshopId, email, sess }; } +/** + * Fetch bodyshop data for socket + * @param bodyshopId + * @param socket + * @returns {Promise<{id: string, intellipay_config: {payment_map: {amex: string}}}|{id: string, intellipay_config: null}|*>} + */ async function getBodyshopForSocket({ bodyshopId, socket }) { const endpoint = process.env.GRAPHQL_ENDPOINT; if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured"); @@ -522,14 +553,35 @@ function registerRREvents({ socket, redisHelpers }) { socket.emit("export-success", { vendor: "rr", jobId: rid, roStatus: result.roStatus }); ack?.({ ok: true, result }); } else { - CreateRRLogEvent(socket, "ERROR", `Export failed`, { roStatus: result?.roStatus, error: result?.error }); + // NEW: classify vendor status for a friendly FE message + const vendorStatusCode = Number( + result?.roStatus?.statusCode ?? result?.roStatus?.StatusCode ?? result?.statusBlocks?.transaction?.statusCode + ); + const cls = classifyRRVendorError({ + code: vendorStatusCode, + message: result?.roStatus?.message ?? result?.roStatus?.Message ?? result?.error ?? "RR export failed" + }); + + CreateRRLogEvent(socket, "ERROR", `Export failed`, { + roStatus: result?.roStatus, + classification: cls + }); + socket.emit("export-failed", { vendor: "rr", jobId: rid, - roStatus: result?.roStatus, - error: result?.error || "RR export failed" + error: result?.error || cls.friendlyMessage || "RR export failed", + ...cls + }); + // Optional: a user-focused channel if you want to show inline banners + socket.emit("rr-user-notice", { jobId: rid, ...cls }); + + ack?.({ + ok: false, + error: cls.friendlyMessage || result?.error || "RR export failed", + result, + classification: cls }); - ack?.({ ok: false, error: result?.error || "RR export failed", result }); } await redisHelpers.setSessionTransactionData( @@ -541,17 +593,31 @@ function registerRREvents({ socket, redisHelpers }) { ); socket.emit("rr-export-job:result", { jobId: rid, bodyshopId, result }); } catch (error) { + const cls = classifyRRVendorError(error); + CreateRRLogEvent(socket, "ERROR", `Error during RR export (selected-customer)`, { error: error.message, + vendorStatusCode: cls.vendorStatusCode, + code: cls.errorCode, + friendly: cls.friendlyMessage, stack: error.stack, jobid: rid }); + try { - socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message }); + socket.emit("export-failed", { + vendor: "rr", + jobId: rid, + error: error.message, + ...cls + }); + // Optional UX hook for inline banners/toasts + socket.emit("rr-user-notice", { jobId: rid, ...cls }); } catch { - // + /* ignore */ } - ack?.({ ok: false, error: error.message }); + + ack?.({ ok: false, error: cls.friendlyMessage || error.message, classification: cls }); } }); diff --git a/server/web-sockets/redisSocketEvents.js b/server/web-sockets/redisSocketEvents.js index 611240073..fa132ed23 100644 --- a/server/web-sockets/redisSocketEvents.js +++ b/server/web-sockets/redisSocketEvents.js @@ -2,7 +2,7 @@ const { admin } = require("../firebase/firebase-handler"); const FortellisLogger = require("../fortellis/fortellis-logger"); const { FortellisJobExport, FortellisSelectedCustomer } = require("../fortellis/fortellis"); const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default; -const registerRREvents = require("./rr-register-socket-events"); +const registerRREvents = require("../rr/rr-register-socket-events"); const redisSocketEvents = ({ io, redisHelpers, ioHelpers, logger }) => { // Destructure helpers locally, but keep full objects available for downstream modules