diff --git a/client/src/App/App.container.backup-2026-03-04.jsx b/client/src/App/App.container.backup-2026-03-04.jsx new file mode 100644 index 000000000..bcfc39fa1 --- /dev/null +++ b/client/src/App/App.container.backup-2026-03-04.jsx @@ -0,0 +1,184 @@ +import { ApolloProvider } from "@apollo/client/react"; +import * as Sentry from "@sentry/react"; +import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react"; +import { ConfigProvider, Grid } from "antd"; +import enLocale from "antd/es/locale/en_US"; +import { useEffect, useMemo } from "react"; +import { CookiesProvider } from "react-cookie"; +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; +import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component"; +import { setDarkMode } from "../redux/application/application.actions"; +import { selectDarkMode } from "../redux/application/application.selectors"; +import { selectCurrentUser } from "../redux/user/user.selectors.js"; +import { signOutStart } from "../redux/user/user.actions"; +import client from "../utils/GraphQLClient"; +import App from "./App"; +import getTheme from "./themeProvider"; + +// Base Split configuration +const config = { + core: { + authorizationKey: import.meta.env.VITE_APP_SPLIT_API, + key: "anon" + } +}; + +function SplitClientProvider({ children }) { + const imexshopid = useSelector((state) => state.user.imexshopid); + const splitClient = useSplitClient({ key: imexshopid || "anon" }); + + useEffect(() => { + if (import.meta.env.DEV && splitClient && imexshopid) { + console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`); + } + }, [splitClient, imexshopid]); + + return children; +} + +function AppContainer() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const currentUser = useSelector(selectCurrentUser); + const isDarkMode = useSelector(selectDarkMode); + const screens = Grid.useBreakpoint(); + const isPhone = !screens.md; + const isUltraWide = Boolean(screens.xxxl); + + const theme = useMemo(() => { + const baseTheme = getTheme(isDarkMode); + + return { + ...baseTheme, + token: { + ...(baseTheme.token || {}), + screenXXXL: 2160 + }, + components: { + ...(baseTheme.components || {}), + Table: { + ...(baseTheme.components?.Table || {}), + cellFontSizeSM: isPhone ? 12 : 13, + cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14, + cellFontSize: isUltraWide ? 15 : 14, + cellPaddingInlineSM: isPhone ? 8 : 10, + cellPaddingInlineMD: isPhone ? 10 : 14, + cellPaddingInline: isUltraWide ? 20 : 16, + cellPaddingBlockSM: isPhone ? 8 : 10, + cellPaddingBlockMD: isPhone ? 10 : 12, + cellPaddingBlock: isUltraWide ? 14 : 12, + selectionColumnWidth: isPhone ? 44 : 52 + } + } + }; + }, [isDarkMode, isPhone, isUltraWide]); + + const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []); + const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []); + const antdPagination = useMemo( + () => ({ + showSizeChanger: !isPhone, + totalBoundaryShowSizeChanger: 100 + }), + [isPhone] + ); + + const antdForm = useMemo( + () => ({ + validateMessages: { + required: t("general.validation.required", { label: "${label}" }) + } + }), + [t] + ); + + // Global seamless logout listener with redirect to /signin + useEffect(() => { + const handleSeamlessLogout = (event) => { + if (event.data?.type !== "seamlessLogoutRequest") return; + + // Only accept messages from the parent window + if (event.source !== window.parent) return; + + const targetOrigin = event.origin || "*"; + + if (currentUser?.authorized !== true) { + window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin); + return; + } + + dispatch(signOutStart()); + window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin); + }; + + window.addEventListener("message", handleSeamlessLogout); + return () => { + window.removeEventListener("message", handleSeamlessLogout); + }; + }, [dispatch, currentUser?.authorized]); + + // Update data-theme attribute (no cleanup to avoid transient style churn) + useEffect(() => { + document.documentElement.dataset.theme = isDarkMode ? "dark" : "light"; + }, [isDarkMode]); + + // Sync darkMode with localStorage + useEffect(() => { + const uid = currentUser?.uid; + + if (!uid) { + dispatch(setDarkMode(false)); + return; + } + + const key = `dark-mode-${uid}`; + const raw = localStorage.getItem(key); + + if (raw == null) { + dispatch(setDarkMode(false)); + return; + } + + try { + dispatch(setDarkMode(Boolean(JSON.parse(raw)))); + } catch { + dispatch(setDarkMode(false)); + } + }, [currentUser?.uid, dispatch]); + + // Persist darkMode + useEffect(() => { + const uid = currentUser?.uid; + if (!uid) return; + + localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode)); + }, [isDarkMode, currentUser?.uid]); + + return ( + + + + + + + + + + + + + ); +} + +export default Sentry.withProfiler(AppContainer); diff --git a/client/src/App/App.container.jsx b/client/src/App/App.container.jsx index bcfc39fa1..8abf59242 100644 --- a/client/src/App/App.container.jsx +++ b/client/src/App/App.container.jsx @@ -1,7 +1,7 @@ import { ApolloProvider } from "@apollo/client/react"; import * as Sentry from "@sentry/react"; import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react"; -import { ConfigProvider, Grid } from "antd"; +import { ConfigProvider } from "antd"; import enLocale from "antd/es/locale/en_US"; import { useEffect, useMemo } from "react"; import { CookiesProvider } from "react-cookie"; @@ -43,47 +43,10 @@ function AppContainer() { const currentUser = useSelector(selectCurrentUser); const isDarkMode = useSelector(selectDarkMode); - const screens = Grid.useBreakpoint(); - const isPhone = !screens.md; - const isUltraWide = Boolean(screens.xxxl); - const theme = useMemo(() => { - const baseTheme = getTheme(isDarkMode); - - return { - ...baseTheme, - token: { - ...(baseTheme.token || {}), - screenXXXL: 2160 - }, - components: { - ...(baseTheme.components || {}), - Table: { - ...(baseTheme.components?.Table || {}), - cellFontSizeSM: isPhone ? 12 : 13, - cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14, - cellFontSize: isUltraWide ? 15 : 14, - cellPaddingInlineSM: isPhone ? 8 : 10, - cellPaddingInlineMD: isPhone ? 10 : 14, - cellPaddingInline: isUltraWide ? 20 : 16, - cellPaddingBlockSM: isPhone ? 8 : 10, - cellPaddingBlockMD: isPhone ? 10 : 12, - cellPaddingBlock: isUltraWide ? 14 : 12, - selectionColumnWidth: isPhone ? 44 : 52 - } - } - }; - }, [isDarkMode, isPhone, isUltraWide]); + const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]); const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []); - const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []); - const antdPagination = useMemo( - () => ({ - showSizeChanger: !isPhone, - totalBoundaryShowSizeChanger: 100 - }), - [isPhone] - ); const antdForm = useMemo( () => ({ @@ -159,16 +122,7 @@ function AppContainer() { return ( - + diff --git a/client/src/App/App.container.pre-rollback-2026-03-04.jsx b/client/src/App/App.container.pre-rollback-2026-03-04.jsx new file mode 100644 index 000000000..bcfc39fa1 --- /dev/null +++ b/client/src/App/App.container.pre-rollback-2026-03-04.jsx @@ -0,0 +1,184 @@ +import { ApolloProvider } from "@apollo/client/react"; +import * as Sentry from "@sentry/react"; +import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react"; +import { ConfigProvider, Grid } from "antd"; +import enLocale from "antd/es/locale/en_US"; +import { useEffect, useMemo } from "react"; +import { CookiesProvider } from "react-cookie"; +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; +import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component"; +import { setDarkMode } from "../redux/application/application.actions"; +import { selectDarkMode } from "../redux/application/application.selectors"; +import { selectCurrentUser } from "../redux/user/user.selectors.js"; +import { signOutStart } from "../redux/user/user.actions"; +import client from "../utils/GraphQLClient"; +import App from "./App"; +import getTheme from "./themeProvider"; + +// Base Split configuration +const config = { + core: { + authorizationKey: import.meta.env.VITE_APP_SPLIT_API, + key: "anon" + } +}; + +function SplitClientProvider({ children }) { + const imexshopid = useSelector((state) => state.user.imexshopid); + const splitClient = useSplitClient({ key: imexshopid || "anon" }); + + useEffect(() => { + if (import.meta.env.DEV && splitClient && imexshopid) { + console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`); + } + }, [splitClient, imexshopid]); + + return children; +} + +function AppContainer() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const currentUser = useSelector(selectCurrentUser); + const isDarkMode = useSelector(selectDarkMode); + const screens = Grid.useBreakpoint(); + const isPhone = !screens.md; + const isUltraWide = Boolean(screens.xxxl); + + const theme = useMemo(() => { + const baseTheme = getTheme(isDarkMode); + + return { + ...baseTheme, + token: { + ...(baseTheme.token || {}), + screenXXXL: 2160 + }, + components: { + ...(baseTheme.components || {}), + Table: { + ...(baseTheme.components?.Table || {}), + cellFontSizeSM: isPhone ? 12 : 13, + cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14, + cellFontSize: isUltraWide ? 15 : 14, + cellPaddingInlineSM: isPhone ? 8 : 10, + cellPaddingInlineMD: isPhone ? 10 : 14, + cellPaddingInline: isUltraWide ? 20 : 16, + cellPaddingBlockSM: isPhone ? 8 : 10, + cellPaddingBlockMD: isPhone ? 10 : 12, + cellPaddingBlock: isUltraWide ? 14 : 12, + selectionColumnWidth: isPhone ? 44 : 52 + } + } + }; + }, [isDarkMode, isPhone, isUltraWide]); + + const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []); + const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []); + const antdPagination = useMemo( + () => ({ + showSizeChanger: !isPhone, + totalBoundaryShowSizeChanger: 100 + }), + [isPhone] + ); + + const antdForm = useMemo( + () => ({ + validateMessages: { + required: t("general.validation.required", { label: "${label}" }) + } + }), + [t] + ); + + // Global seamless logout listener with redirect to /signin + useEffect(() => { + const handleSeamlessLogout = (event) => { + if (event.data?.type !== "seamlessLogoutRequest") return; + + // Only accept messages from the parent window + if (event.source !== window.parent) return; + + const targetOrigin = event.origin || "*"; + + if (currentUser?.authorized !== true) { + window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin); + return; + } + + dispatch(signOutStart()); + window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin); + }; + + window.addEventListener("message", handleSeamlessLogout); + return () => { + window.removeEventListener("message", handleSeamlessLogout); + }; + }, [dispatch, currentUser?.authorized]); + + // Update data-theme attribute (no cleanup to avoid transient style churn) + useEffect(() => { + document.documentElement.dataset.theme = isDarkMode ? "dark" : "light"; + }, [isDarkMode]); + + // Sync darkMode with localStorage + useEffect(() => { + const uid = currentUser?.uid; + + if (!uid) { + dispatch(setDarkMode(false)); + return; + } + + const key = `dark-mode-${uid}`; + const raw = localStorage.getItem(key); + + if (raw == null) { + dispatch(setDarkMode(false)); + return; + } + + try { + dispatch(setDarkMode(Boolean(JSON.parse(raw)))); + } catch { + dispatch(setDarkMode(false)); + } + }, [currentUser?.uid, dispatch]); + + // Persist darkMode + useEffect(() => { + const uid = currentUser?.uid; + if (!uid) return; + + localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode)); + }, [isDarkMode, currentUser?.uid]); + + return ( + + + + + + + + + + + + + ); +} + +export default Sentry.withProfiler(AppContainer); diff --git a/client/src/App/App.styles.scss b/client/src/App/App.styles.scss index aedfbd15c..5f66030b5 100644 --- a/client/src/App/App.styles.scss +++ b/client/src/App/App.styles.scss @@ -471,34 +471,34 @@ // padding: 0; //} -/* globally allow shrink inside table cells */ -.prod-list-table .ant-table-cell, -.prod-list-table .ant-table-cell > * { - min-width: 0; -} - -/* common AntD offenders */ -.prod-list-table > .ant-table-cell .ant-space, -.ant-table-cell .ant-space-item { - min-width: 0; -} - -/* Keep your custom header content on the left, push AntD sorter to the far right */ -.prod-list-table .ant-table-column-sorters { - display: flex !important; - align-items: center; - width: 100%; -} - -.prod-list-table .ant-table-column-title { - flex: 1 1 auto; - min-width: 0; /* allows ellipsis to work */ -} - -.prod-list-table .ant-table-column-sorter { - margin-left: auto; - flex: 0 0 auto; -} +///* globally allow shrink inside table cells */ +//.prod-list-table .ant-table-cell, +//.prod-list-table .ant-table-cell > * { +// min-width: 0; +//} +// +///* common AntD offenders */ +//.prod-list-table > .ant-table-cell .ant-space, +//.ant-table-cell .ant-space-item { +// min-width: 0; +//} +// +///* Keep your custom header content on the left, push AntD sorter to the far right */ +//.prod-list-table .ant-table-column-sorters { +// display: flex !important; +// align-items: center; +// width: 100%; +//} +// +//.prod-list-table .ant-table-column-title { +// flex: 1 1 auto; +// min-width: 0; /* allows ellipsis to work */ +//} +// +//.prod-list-table .ant-table-column-sorter { +// margin-left: auto; +// flex: 0 0 auto; +//} .global-search-autocomplete-fix { diff --git a/client/src/components/responsive-table/responsive-table.component.backup-2026-03-04.jsx b/client/src/components/responsive-table/responsive-table.component.backup-2026-03-04.jsx new file mode 100644 index 000000000..94ed7fc02 --- /dev/null +++ b/client/src/components/responsive-table/responsive-table.component.backup-2026-03-04.jsx @@ -0,0 +1,99 @@ +import { Grid, Table } from "antd"; +import { useMemo } from "react"; +import "./responsive-table.styles.scss"; + +function ResponsiveTable({ className, columns, mobileColumnKeys, scroll, tableLayout, ...rest }) { + const screens = Grid.useBreakpoint(); + const isPhone = !screens.md; + const isCompactViewport = !screens.lg; + const prefersHorizontalScroll = isPhone || isCompactViewport; + const isResponsiveFilteringEnabled = ["1", "true", "yes", "on"].includes( + String(import.meta.env.VITE_APP_ENABLE_RESPONSIVE_TABLE_FILTERING || "") + .trim() + .toLowerCase() + ); + + const resolvedColumns = useMemo(() => { + if ( + !isResponsiveFilteringEnabled || + !Array.isArray(columns) || + !isPhone || + !Array.isArray(mobileColumnKeys) || + mobileColumnKeys.length === 0 + ) { + return columns; + } + + const visibleColumnKeys = new Set(mobileColumnKeys); + const filteredColumns = columns.filter((column) => { + const key = column?.key ?? column?.dataIndex; + + // Keep columns with no stable key to avoid accidental loss. + if (key == null) return true; + + if (Array.isArray(key)) { + return key.some((part) => visibleColumnKeys.has(part)); + } + + return visibleColumnKeys.has(key); + }); + + return filteredColumns.length > 0 ? filteredColumns : columns; + }, [columns, isPhone, isResponsiveFilteringEnabled, mobileColumnKeys]); + + const resolvedScroll = useMemo(() => { + if (prefersHorizontalScroll) { + if (scroll == null) { + return { x: "max-content" }; + } + + if (typeof scroll !== "object" || Array.isArray(scroll)) { + return scroll; + } + + const { x, ...baseScroll } = scroll; + + return { ...baseScroll, x: x ?? "max-content" }; + } + + if (scroll == null) { + // Explicitly override ConfigProvider table.scroll desktop defaults. + return {}; + } + + if (typeof scroll !== "object" || Array.isArray(scroll)) { + return scroll; + } + + const { x, ...desktopScroll } = scroll; + + // On desktop we prefer fitting columns with ellipsis over forced horizontal scroll. + if (x == null) { + return desktopScroll; + } + + return desktopScroll; + }, [prefersHorizontalScroll, scroll]); + + const resolvedTableLayout = tableLayout ?? (prefersHorizontalScroll ? "auto" : "fixed"); + const responsiveClassName = prefersHorizontalScroll ? undefined : "responsive-table-fit"; + const resolvedClassName = [responsiveClassName, className].filter(Boolean).join(" "); + + return ( + + ); +} + +ResponsiveTable.Summary = Table.Summary; +ResponsiveTable.Column = Table.Column; +ResponsiveTable.ColumnGroup = Table.ColumnGroup; +ResponsiveTable.SELECTION_COLUMN = Table.SELECTION_COLUMN; +ResponsiveTable.EXPAND_COLUMN = Table.EXPAND_COLUMN; + +export default ResponsiveTable; diff --git a/client/src/components/responsive-table/responsive-table.component.jsx b/client/src/components/responsive-table/responsive-table.component.jsx index 94ed7fc02..c6424db82 100644 --- a/client/src/components/responsive-table/responsive-table.component.jsx +++ b/client/src/components/responsive-table/responsive-table.component.jsx @@ -1,93 +1,7 @@ -import { Grid, Table } from "antd"; -import { useMemo } from "react"; -import "./responsive-table.styles.scss"; +import { Table } from "antd"; -function ResponsiveTable({ className, columns, mobileColumnKeys, scroll, tableLayout, ...rest }) { - const screens = Grid.useBreakpoint(); - const isPhone = !screens.md; - const isCompactViewport = !screens.lg; - const prefersHorizontalScroll = isPhone || isCompactViewport; - const isResponsiveFilteringEnabled = ["1", "true", "yes", "on"].includes( - String(import.meta.env.VITE_APP_ENABLE_RESPONSIVE_TABLE_FILTERING || "") - .trim() - .toLowerCase() - ); - - const resolvedColumns = useMemo(() => { - if ( - !isResponsiveFilteringEnabled || - !Array.isArray(columns) || - !isPhone || - !Array.isArray(mobileColumnKeys) || - mobileColumnKeys.length === 0 - ) { - return columns; - } - - const visibleColumnKeys = new Set(mobileColumnKeys); - const filteredColumns = columns.filter((column) => { - const key = column?.key ?? column?.dataIndex; - - // Keep columns with no stable key to avoid accidental loss. - if (key == null) return true; - - if (Array.isArray(key)) { - return key.some((part) => visibleColumnKeys.has(part)); - } - - return visibleColumnKeys.has(key); - }); - - return filteredColumns.length > 0 ? filteredColumns : columns; - }, [columns, isPhone, isResponsiveFilteringEnabled, mobileColumnKeys]); - - const resolvedScroll = useMemo(() => { - if (prefersHorizontalScroll) { - if (scroll == null) { - return { x: "max-content" }; - } - - if (typeof scroll !== "object" || Array.isArray(scroll)) { - return scroll; - } - - const { x, ...baseScroll } = scroll; - - return { ...baseScroll, x: x ?? "max-content" }; - } - - if (scroll == null) { - // Explicitly override ConfigProvider table.scroll desktop defaults. - return {}; - } - - if (typeof scroll !== "object" || Array.isArray(scroll)) { - return scroll; - } - - const { x, ...desktopScroll } = scroll; - - // On desktop we prefer fitting columns with ellipsis over forced horizontal scroll. - if (x == null) { - return desktopScroll; - } - - return desktopScroll; - }, [prefersHorizontalScroll, scroll]); - - const resolvedTableLayout = tableLayout ?? (prefersHorizontalScroll ? "auto" : "fixed"); - const responsiveClassName = prefersHorizontalScroll ? undefined : "responsive-table-fit"; - const resolvedClassName = [responsiveClassName, className].filter(Boolean).join(" "); - - return ( -
- ); +function ResponsiveTable(props) { + return
; } ResponsiveTable.Summary = Table.Summary; 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 7d6daed41..52397b90e 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 @@ -47,7 +47,7 @@ export function TimeTicketModalComponent({ } = useTreatmentsWithConfig({ attributes: {}, names: ["Enhanced_Payroll"], - splitKey: bodyshop.imexshopid + splitKey: bodyshop?.imexshopid }); const [loadLineTicketData, { loading, data: lineTicketData, refetch }] = useLazyQuery(GET_LINE_TICKET_BY_PK, { @@ -347,7 +347,7 @@ export function LaborAllocationContainer({ } = useTreatmentsWithConfig({ attributes: {}, names: ["Enhanced_Payroll"], - splitKey: bodyshop.imexshopid + splitKey: bodyshop?.imexshopid }); if (loading) return ; diff --git a/server/rr/rr-customers.js b/server/rr/rr-customers.js index 0895bf5b4..7c5fe671f 100644 --- a/server/rr/rr-customers.js +++ b/server/rr/rr-customers.js @@ -1,6 +1,7 @@ const { RRClient } = require("./lib/index.cjs"); const { getRRConfigFromBodyshop } = require("./rr-config"); const CreateRRLogEvent = require("./rr-logger-event"); +const { withRRRequestXml } = require("./rr-log-xml"); const InstanceManager = require("../utils/instanceMgr").default; /** @@ -217,14 +218,24 @@ const createRRCustomer = async ({ bodyshop, job, overrides = {}, socket }) => { try { response = await client.insertCustomer(safePayload, opts); // Very noisy; only show when log level is cranked to SILLY - CreateRRLogEvent(socket, "SILLY", "{CU} insertCustomer: raw response", { response }); + CreateRRLogEvent( + socket, + "SILLY", + "{CU} insertCustomer: raw response", + withRRRequestXml(response, { response }) + ); } catch (e) { - CreateRRLogEvent(socket, "ERROR", "RR insertCustomer transport error", { - message: e?.message, - code: e?.code, - status: e?.meta?.status || e?.status, - payload: safePayload - }); + CreateRRLogEvent( + socket, + "ERROR", + "RR insertCustomer transport error", + withRRRequestXml(e, { + message: e?.message, + code: e?.code, + status: e?.meta?.status || e?.status, + payload: safePayload + }) + ); throw e; } @@ -233,12 +244,17 @@ const createRRCustomer = async ({ bodyshop, job, overrides = {}, socket }) => { let customerNo = data?.dmsRecKey; if (!customerNo) { - CreateRRLogEvent(socket, "ERROR", "RR insertCustomer returned no dmsRecKey/custNo", { - status: trx?.status, - statusCode: trx?.statusCode, - message: trx?.message, - data - }); + CreateRRLogEvent( + socket, + "ERROR", + "RR insertCustomer returned no dmsRecKey/custNo", + withRRRequestXml(response, { + status: trx?.status, + statusCode: trx?.statusCode, + message: trx?.message, + data + }) + ); throw new Error( `RR insertCustomer returned no dmsRecKey (status=${trx?.status ?? "?"} code=${trx?.statusCode ?? "?"}${ diff --git a/server/rr/rr-export-logs.js b/server/rr/rr-export-logs.js index 184d54a4f..7dff70f6c 100644 --- a/server/rr/rr-export-logs.js +++ b/server/rr/rr-export-logs.js @@ -1,6 +1,7 @@ const { GraphQLClient } = require("graphql-request"); const queries = require("../graphql-client/queries"); const CreateRRLogEvent = require("./rr-logger-event"); +const { extractRRXmlPair } = require("./rr-log-xml"); /** Get bearer token from the socket (same approach used elsewhere) */ const getAuthToken = (socket) => @@ -178,11 +179,23 @@ const insertRRFailedExportLog = async ({ socket, jobId, job, bodyshop, error, cl const client = new GraphQLClient(endpoint, {}); client.setHeaders({ Authorization: `Bearer ${token}` }); + const { requestXml, responseXml } = extractRRXmlPair(error); + const xmlFromError = + requestXml || responseXml + ? { + ...(requestXml ? { request: requestXml } : {}), + ...(responseXml ? { response: responseXml } : {}) + } + : undefined; + const meta = buildRRExportMeta({ result, extra: { error: error?.message || String(error), - classification: classification || undefined + classification: classification || undefined, + ...(requestXml ? { requestXml } : {}), + ...(responseXml ? { responseXml } : {}), + ...(xmlFromError && !result?.xml ? { xml: xmlFromError } : {}) } }); diff --git a/server/rr/rr-job-export.js b/server/rr/rr-job-export.js index 9cc074ab6..a5248bcf2 100644 --- a/server/rr/rr-job-export.js +++ b/server/rr/rr-job-export.js @@ -1,6 +1,7 @@ const { buildRRRepairOrderPayload } = require("./rr-job-helpers"); const { buildClientAndOpts } = require("./rr-lookup"); const CreateRRLogEvent = require("./rr-logger-event"); +const { withRRRequestXml } = require("./rr-log-xml"); const { extractRrResponsibilityCenters } = require("./rr-responsibility-centers"); const CdkCalculateAllocations = require("./rr-calculate-allocations").default; const { resolveRROpCodeFromBodyshop } = require("./rr-utils"); @@ -147,10 +148,7 @@ const createMinimalRRRepairOrder = async (args) => { const response = await client.createRepairOrder(payload, finalOpts); - CreateRRLogEvent(socket, "INFO", "RR minimal Repair Order created", { - payload, - response - }); + CreateRRLogEvent(socket, "INFO", "RR minimal Repair Order created", withRRRequestXml(response, { payload, response })); const data = response?.data || null; const statusBlocks = response?.statusBlocks || {}; @@ -327,7 +325,7 @@ const updateRRRepairOrderWithFullData = async (args) => { // Without this, Reynolds won't recognize the OpCode when we send rogg operations // The rolabor section tells Reynolds "these jobs exist" even with minimal data - CreateRRLogEvent(socket, "INFO", "Sending full data for early RO (using create with roNo)", { + CreateRRLogEvent(socket, "INFO", "Preparing full data for early RO (using create with roNo)", { roNo: String(roNo), hasRolabor: !!payload.rolabor, hasRogg: !!payload.rogg, @@ -338,10 +336,18 @@ const updateRRRepairOrderWithFullData = async (args) => { // Reynolds will merge this with the existing RO header const response = await client.createRepairOrder(payload, finalOpts); - CreateRRLogEvent(socket, "INFO", "RR Repair Order full data sent", { - payload, - response - }); + CreateRRLogEvent( + socket, + "INFO", + "Sending full data for early RO (using create with roNo)", + withRRRequestXml(response, { + roNo: String(roNo), + hasRolabor: !!payload.rolabor, + hasRogg: !!payload.rogg, + payload, + response + }) + ); const data = response?.data || null; const statusBlocks = response?.statusBlocks || {}; @@ -501,10 +507,7 @@ const exportJobToRR = async (args) => { const response = await client.createRepairOrder(payload, finalOpts); - CreateRRLogEvent(socket, "INFO", "RR raw Repair Order created", { - payload, - response - }); + CreateRRLogEvent(socket, "INFO", "RR raw Repair Order created", withRRRequestXml(response, { payload, response })); const data = response?.data || null; const statusBlocks = response?.statusBlocks || {}; @@ -603,10 +606,15 @@ const finalizeRRRepairOrder = async (args) => { const rrRes = await client.updateRepairOrder(payload, finalOpts); - CreateRRLogEvent(socket, "SILLY", "RR Repair Order finalized", { - payload, - response: rrRes - }); + CreateRRLogEvent( + socket, + "SILLY", + "RR Repair Order finalized", + withRRRequestXml(rrRes, { + payload, + response: rrRes + }) + ); const data = rrRes?.data || null; const statusBlocks = rrRes?.statusBlocks || {}; diff --git a/server/rr/rr-log-xml.js b/server/rr/rr-log-xml.js new file mode 100644 index 000000000..0761d3418 --- /dev/null +++ b/server/rr/rr-log-xml.js @@ -0,0 +1,63 @@ +/** + * Extract request/response XML from RR response/result shapes. + * @param rrObj + * @returns {{requestXml: string|null, responseXml: string|null}} + */ +const extractRRXmlPair = (rrObj) => { + const xml = rrObj?.xml ?? rrObj?.meta?.xml; + + let requestXml = null; + let responseXml = null; + + if (typeof xml === "string") { + requestXml = xml; + } else { + if (typeof xml?.request === "string") requestXml = xml.request; + else if (typeof xml?.req === "string") requestXml = xml.req; + else if (typeof xml?.starXml === "string") requestXml = xml.starXml; + if (typeof xml?.response === "string") responseXml = xml.response; + } + + if (!requestXml && typeof rrObj?.requestXml === "string") requestXml = rrObj.requestXml; + if (!requestXml && typeof rrObj?.meta?.requestXml === "string") requestXml = rrObj.meta.requestXml; + if (!requestXml && typeof rrObj?.meta?.reqXml === "string") requestXml = rrObj.meta.reqXml; + if (!requestXml && typeof rrObj?.meta?.request === "string") requestXml = rrObj.meta.request; + if (!responseXml && typeof rrObj?.responseXml === "string") responseXml = rrObj.responseXml; + if (!responseXml && typeof rrObj?.meta?.responseXml === "string") responseXml = rrObj.meta.responseXml; + if (!responseXml && typeof rrObj?.meta?.resXml === "string") responseXml = rrObj.meta.resXml; + if (!responseXml && typeof rrObj?.meta?.response === "string") responseXml = rrObj.meta.response; + + // If wrapped HTTP response data contains raw XML, surface it. + if (!responseXml && typeof rrObj?.response?.data === "string") { + const xmlData = rrObj.response.data.trim(); + if (xmlData.startsWith("<")) responseXml = xmlData; + } + + // Try one level down when errors are wrapped. + if ((!requestXml || !responseXml) && rrObj?.cause && rrObj.cause !== rrObj) { + const nested = extractRRXmlPair(rrObj.cause); + if (!requestXml) requestXml = nested.requestXml; + if (!responseXml) responseXml = nested.responseXml; + } + + return { requestXml, responseXml }; +}; + +/** + * Add Reynolds request/response XML to RR log metadata when available. + * @param rrObj + * @param meta + * @returns {*} + */ +const withRRRequestXml = (rrObj, meta = {}) => { + const { requestXml, responseXml } = extractRRXmlPair(rrObj); + const xmlMeta = {}; + if (requestXml) xmlMeta.requestXml = requestXml; + if (responseXml) xmlMeta.responseXml = responseXml; + return Object.keys(xmlMeta).length ? { ...meta, ...xmlMeta } : meta; +}; + +module.exports = { + extractRRXmlPair, + withRRRequestXml +}; diff --git a/server/rr/rr-register-socket-events.js b/server/rr/rr-register-socket-events.js index bdb51580e..92db91d99 100644 --- a/server/rr/rr-register-socket-events.js +++ b/server/rr/rr-register-socket-events.js @@ -12,6 +12,7 @@ const { createRRCustomer } = require("./rr-customers"); const { ensureRRServiceVehicle } = require("./rr-service-vehicles"); const { classifyRRVendorError } = require("./rr-errors"); const { markRRExportSuccess, insertRRFailedExportLog } = require("./rr-export-logs"); +const { withRRRequestXml } = require("./rr-log-xml"); const { makeVehicleSearchPayloadFromJob, ownersFromVinBlocks, @@ -48,46 +49,6 @@ const resolveJobId = (explicit, payload, job) => explicit || payload?.jobId || j */ const resolveVin = ({ tx, job }) => tx?.jobData?.vin || job?.v_vin || null; -/** - * Extract request/response XML from RR response/result shapes. - * @param rrObj - * @returns {{requestXml: string|null, responseXml: string|null}} - */ -const extractRRXmlPair = (rrObj) => { - const xml = rrObj?.xml; - - let requestXml = null; - let responseXml = null; - - if (typeof xml === "string") { - requestXml = xml; - } else { - if (typeof xml?.request === "string") requestXml = xml.request; - else if (typeof xml?.req === "string") requestXml = xml.req; - else if (typeof xml?.starXml === "string") requestXml = xml.starXml; - if (typeof xml?.response === "string") responseXml = xml.response; - } - - if (!requestXml && typeof rrObj?.requestXml === "string") requestXml = rrObj.requestXml; - if (!responseXml && typeof rrObj?.responseXml === "string") responseXml = rrObj.responseXml; - - return { requestXml, responseXml }; -}; - -/** - * Add Reynolds request/response XML to RR log metadata when available. - * @param rrObj - * @param meta - * @returns {*} - */ -const withRRRequestXml = (rrObj, meta = {}) => { - const { requestXml, responseXml } = extractRRXmlPair(rrObj); - const xmlMeta = {}; - if (requestXml) xmlMeta.requestXml = requestXml; - if (responseXml) xmlMeta.responseXml = responseXml; - return Object.keys(xmlMeta).length ? { ...meta, ...xmlMeta } : meta; -}; - /** * Sort vehicle owners first in the list, preserving original order otherwise. * @param list @@ -279,7 +240,12 @@ const rrMultiCustomerSearch = async ({ bodyshop, job, socket, redisHelpers }) => const multiResponse = await rrCombinedSearch(bodyshop, q); - CreateRRLogEvent(socket, "SILLY", "Multi Customer Search - raw combined search", { response: multiResponse }); + CreateRRLogEvent( + socket, + "SILLY", + "Multi Customer Search - raw combined search", + withRRRequestXml(multiResponse, { response: multiResponse }) + ); if (fromVin) { const multiBlocks = Array.isArray(multiResponse?.data) ? multiResponse.data : []; @@ -300,7 +266,7 @@ const rrMultiCustomerSearch = async ({ bodyshop, job, socket, redisHelpers }) => const norm = normalizeCustomerCandidates(multiResponse, { ownersSet }); merged.push(...norm); } catch (e) { - CreateRRLogEvent(socket, "WARN", "Multi-search subquery failed", { kind: q.kind, error: e.message }); + CreateRRLogEvent(socket, "WARN", "Multi-search subquery failed", withRRRequestXml(e, { kind: q.kind, error: e.message })); } } @@ -348,7 +314,7 @@ const registerRREvents = ({ socket, redisHelpers }) => { count: decorated.length }); } catch (e) { - CreateRRLogEvent(socket, "ERROR", "RR combined lookup error", { error: e.message, jobid }); + CreateRRLogEvent(socket, "ERROR", "RR combined lookup error", withRRRequestXml(e, { error: e.message, jobid })); cb?.({ jobid, error: e.message }); } }); @@ -425,7 +391,7 @@ const registerRREvents = ({ socket, redisHelpers }) => { fromCache }); } catch (err) { - CreateRRLogEvent(socket, "ERROR", "rr-get-advisors: failed", { error: err?.message }); + CreateRRLogEvent(socket, "ERROR", "rr-get-advisors: failed", withRRRequestXml(err, { error: err?.message })); ack?.({ ok: false, error: err?.message || "get advisors failed" }); } }); @@ -496,11 +462,16 @@ const registerRREvents = ({ socket, redisHelpers }) => { anyOwner: decorated.some((c) => c.vinOwner || c.isVehicleOwner) }); } catch (error) { - CreateRRLogEvent(socket, "ERROR", `Error during RR early RO creation (prepare)`, { - error: error.message, - stack: error.stack, - jobid: rid - }); + CreateRRLogEvent( + socket, + "ERROR", + `Error during RR early RO creation (prepare)`, + withRRRequestXml(error, { + error: error.message, + stack: error.stack, + jobid: rid + }) + ); try { socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message }); @@ -597,7 +568,12 @@ const registerRREvents = ({ socket, redisHelpers }) => { if (vehQ && vehQ.kind === "vin" && job?.v_vin) { const vinResponse = await rrCombinedSearch(bodyshop, vehQ); - CreateRRLogEvent(socket, "SILLY", `VIN owner pre-check response (early RO)`, { response: vinResponse }); + CreateRRLogEvent( + socket, + "SILLY", + `VIN owner pre-check response (early RO)`, + withRRRequestXml(vinResponse, { response: vinResponse }) + ); const vinBlocks = Array.isArray(vinResponse?.data) ? vinResponse.data : []; @@ -630,9 +606,14 @@ const registerRREvents = ({ socket, redisHelpers }) => { } } } catch (e) { - CreateRRLogEvent(socket, "WARN", `VIN owner pre-check failed; continuing with selected customer (early RO)`, { - error: e?.message - }); + CreateRRLogEvent( + socket, + "WARN", + `VIN owner pre-check failed; continuing with selected customer (early RO)`, + withRRRequestXml(e, { + error: e?.message + }) + ); } // Cache final/effective customer selection @@ -905,14 +886,19 @@ const registerRREvents = ({ socket, redisHelpers }) => { } catch (error) { const cls = classifyRRVendorError(error); - CreateRRLogEvent(socket, "ERROR", `Error during RR early RO creation (customer-selected)`, { - error: error.message, - vendorStatusCode: cls.vendorStatusCode, - code: cls.errorCode, - friendly: cls.friendlyMessage, - stack: error.stack, - jobid: rid - }); + CreateRRLogEvent( + socket, + "ERROR", + `Error during RR early RO creation (customer-selected)`, + withRRRequestXml(error, { + error: error.message, + vendorStatusCode: cls.vendorStatusCode, + code: cls.errorCode, + friendly: cls.friendlyMessage, + stack: error.stack, + jobid: rid + }) + ); try { if (!bodyshop || !job) { @@ -1097,7 +1083,28 @@ const registerRREvents = ({ socket, redisHelpers }) => { roNo: job.dms_id }); + CreateRRLogEvent( + socket, + "SILLY", + "{4.1} RR RO update response received", + withRRRequestXml(result, { + dmsRoNo: job.dms_id, + success: !!result?.success + }) + ); + if (!result?.success) { + CreateRRLogEvent( + socket, + "ERROR", + "RR Repair Order update failed", + withRRRequestXml(result, { + jobId: rid, + dmsRoNo: job.dms_id, + roStatus: result?.roStatus, + statusBlocks: result?.statusBlocks + }) + ); throw new Error(result?.roStatus?.message || "Failed to update RR Repair Order"); } @@ -1154,11 +1161,16 @@ const registerRREvents = ({ socket, redisHelpers }) => { anyOwner: decorated.some((c) => c.vinOwner || c.isVehicleOwner) }); } catch (error) { - CreateRRLogEvent(socket, "ERROR", `Error during RR export (prepare)`, { - error: error.message, - stack: error.stack, - jobid: rid - }); + CreateRRLogEvent( + socket, + "ERROR", + `Error during RR export (prepare)`, + withRRRequestXml(error, { + error: error.message, + stack: error.stack, + jobid: rid + }) + ); try { socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message }); @@ -1220,7 +1232,12 @@ const registerRREvents = ({ socket, redisHelpers }) => { if (vehQ && vehQ.kind === "vin" && job?.v_vin) { const vinResponse = await rrCombinedSearch(bodyshop, vehQ); - CreateRRLogEvent(socket, "SILLY", `VIN owner pre-check response`, { response: vinResponse }); + CreateRRLogEvent( + socket, + "SILLY", + `VIN owner pre-check response`, + withRRRequestXml(vinResponse, { response: vinResponse }) + ); const vinBlocks = Array.isArray(vinResponse?.data) ? vinResponse.data : []; @@ -1253,9 +1270,14 @@ const registerRREvents = ({ socket, redisHelpers }) => { } } } catch (e) { - CreateRRLogEvent(socket, "WARN", `VIN owner pre-check failed; continuing with selected customer`, { - error: e?.message - }); + CreateRRLogEvent( + socket, + "WARN", + `VIN owner pre-check failed; continuing with selected customer`, + withRRRequestXml(e, { + error: e?.message + }) + ); } // Cache final/effective customer selection @@ -1532,14 +1554,19 @@ const registerRREvents = ({ socket, redisHelpers }) => { } 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 - }); + CreateRRLogEvent( + socket, + "ERROR", + `Error during RR export (selected-customer)`, + withRRRequestXml(error, { + error: error.message, + vendorStatusCode: cls.vendorStatusCode, + code: cls.errorCode, + friendly: cls.friendlyMessage, + stack: error.stack, + jobid: rid + }) + ); try { if (!bodyshop || !job) { @@ -1707,14 +1734,19 @@ const registerRREvents = ({ socket, redisHelpers }) => { } } catch (error) { const cls = classifyRRVendorError(error); - CreateRRLogEvent(socket, "ERROR", `Error during RR finalize`, { - error: error.message, - vendorStatusCode: cls.vendorStatusCode, - code: cls.errorCode, - friendly: cls.friendlyMessage, - stack: error.stack, - jobid: rid - }); + CreateRRLogEvent( + socket, + "ERROR", + `Error during RR finalize`, + withRRRequestXml(error, { + error: error.message, + vendorStatusCode: cls.vendorStatusCode, + code: cls.errorCode, + friendly: cls.friendlyMessage, + stack: error.stack, + jobid: rid + }) + ); try { if (!bodyshop || !job) { diff --git a/server/rr/rr-service-vehicles.js b/server/rr/rr-service-vehicles.js index 597a2ea90..72c9bd559 100644 --- a/server/rr/rr-service-vehicles.js +++ b/server/rr/rr-service-vehicles.js @@ -1,5 +1,6 @@ const { buildClientAndOpts, rrCombinedSearch } = require("./rr-lookup"); const CreateRRLogEvent = require("./rr-logger-event"); +const { withRRRequestXml } = require("./rr-log-xml"); /** * Pick and normalize VIN from inputs * @param vin @@ -168,9 +169,12 @@ const ensureRRServiceVehicle = async (args = {}) => { if (bodyshop) { const combinedSearchResponse = await rrCombinedSearch(bodyshop, { kind: "vin", vin: vinStr, maxResults: 50 }); - CreateRRLogEvent(socket, "silly", "{SV} Preflight combined search by VIN: raw response", { - response: combinedSearchResponse - }); + CreateRRLogEvent( + socket, + "silly", + "{SV} Preflight combined search by VIN: raw response", + withRRRequestXml(combinedSearchResponse, { response: combinedSearchResponse }) + ); owners = ownersFromCombined(combinedSearchResponse, vinStr); } @@ -194,10 +198,15 @@ const ensureRRServiceVehicle = async (args = {}) => { } } catch (e) { // Preflight shouldn't be fatal; log and continue to insert (idempotency will still be handled) - CreateRRLogEvent(socket, "warn", "{SV} VIN preflight lookup failed; continuing to insert", { - vin: vinStr, - error: e?.message - }); + CreateRRLogEvent( + socket, + "warn", + "{SV} VIN preflight lookup failed; continuing to insert", + withRRRequestXml(e, { + vin: vinStr, + error: e?.message + }) + ); } // Vendor says: MODEL DESCRIPTION HAS MAXIMUM LENGTH OF 20 @@ -271,7 +280,7 @@ const ensureRRServiceVehicle = async (args = {}) => { try { const res = await client.insertServiceVehicle(insertPayload, insertOpts); - CreateRRLogEvent(socket, "silly", "{SV} insertServiceVehicle: raw response", { res }); + CreateRRLogEvent(socket, "silly", "{SV} insertServiceVehicle: raw response", withRRRequestXml(res, { res })); const data = res?.data ?? {}; const svId = data?.dmsRecKey || data?.svId || undefined; @@ -309,11 +318,16 @@ const ensureRRServiceVehicle = async (args = {}) => { }; } - CreateRRLogEvent(socket, "error", "{SV} insertServiceVehicle: failure", { - message: e?.message, - code: e?.code, - status: e?.meta?.status || e?.status - }); + CreateRRLogEvent( + socket, + "error", + "{SV} insertServiceVehicle: failure", + withRRRequestXml(e, { + message: e?.message, + code: e?.code, + status: e?.meta?.status || e?.status + }) + ); throw e; }