diff --git a/client/src/components/dms-allocations-summary/dms-allocations-summary.component.jsx b/client/src/components/dms-allocations-summary/dms-allocations-summary.component.jsx index ca8814ae9..f881e9fbd 100644 --- a/client/src/components/dms-allocations-summary/dms-allocations-summary.component.jsx +++ b/client/src/components/dms-allocations-summary/dms-allocations-summary.component.jsx @@ -40,6 +40,7 @@ export function DmsAllocationsSummary({ socket, bodyshop, jobId, title }) { // ✅ RR takes precedence over Fortellis if (dms === "rr") { wsssocket.emit("rr-calculate-allocations", jobId, (ack) => { + console.dir({ ack }); setAllocationsSummary(ack); socket.allocationsSummary = ack; }); diff --git a/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx b/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx index 4ab9a6097..b0a2b21b6 100644 --- a/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx +++ b/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx @@ -83,7 +83,7 @@ export function DmsCustomerSelector({ bodyshop, jobid }) { const onUseSelected = () => { setOpen(false); if (dmsType === "rr") { - wsssocket.emit("rr-selected-customer", { bodyshopId, selectedCustomerId: selectedCustomer, jobid }); + wsssocket.emit("rr-selected-customer", { bodyshopId, custNo: selectedCustomer, jobId: jobid }); } else if (Fortellis.treatment === "on") { wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: selectedCustomer, jobid }); } else { @@ -95,9 +95,11 @@ export function DmsCustomerSelector({ bodyshop, jobid }) { const onUseGeneric = () => { setOpen(false); const generic = bodyshop.cdk_configuration?.generic_customer_number || null; - + console.dir({ bodyshop, generic }); if (dmsType === "rr") { - wsssocket.emit("rr-selected-customer", { bodyshopId, selectedCustomerId: generic, jobid }); + if (generic) { + wsssocket.emit("rr-selected-customer", { bodyshopId, custNo: generic, jobId: jobid }); + } } else if (Fortellis.treatment === "on") { wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: generic, jobid }); } else { @@ -109,7 +111,8 @@ export function DmsCustomerSelector({ bodyshop, jobid }) { const onCreateNew = () => { setOpen(false); if (dmsType === "rr") { - wsssocket.emit("rr-selected-customer", { bodyshopId, selectedCustomerId: null, jobid }); + // RR equivalent: signal create intent explicitly + wsssocket.emit("rr-selected-customer", { bodyshopId, create: true, jobId: jobid }); } else if (Fortellis.treatment === "on") { wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: null, jobid }); } else { @@ -201,36 +204,13 @@ export function DmsCustomerSelector({ bodyshop, jobid }) { } ]; - // NEW: RR columns (aligned with RR CombinedSearch-style payloads; falls back gracefully) const rrColumns = [ - { - title: t("jobs.fields.dms.id"), - dataIndex: "CustomerId", - key: "CustomerId" - }, + { title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" }, { title: t("jobs.fields.dms.name1"), - key: "CustomerName", - sorter: (a, b) => - alphaSort( - (a.CustomerName?.FirstName || "") + " " + (a.CustomerName?.LastName || ""), - (b.CustomerName?.FirstName || "") + " " + (b.CustomerName?.LastName || "") - ), - render: (record) => `${record.CustomerName?.FirstName || ""} ${record.CustomerName?.LastName || ""}`.trim() - }, - { - title: t("jobs.fields.dms.address"), - key: "Address", - render: (record) => { - const a = record.PostalAddress || record.Address || {}; - const l1 = a.AddressLine1 || a.Line1 || ""; - const l2 = a.AddressLine2 || a.Line2 || ""; - const city = a.City || ""; - const st = a.State || a.StateProvince || ""; - const pc = a.PostalCode || ""; - const ctry = a.Country || ""; - return `${l1}${l2 ? `, ${l2}` : ""}, ${city} ${st} ${pc} ${ctry}`.trim(); - } + dataIndex: "name", + key: "name", + sorter: (a, b) => alphaSort(a?.name, b?.name) } ]; @@ -247,7 +227,7 @@ export function DmsCustomerSelector({ bodyshop, jobid }) { const rowKeyFn = dmsType === "rr" - ? (record) => record.CustomerId || record.customerId + ? (record) => record.custNo : dmsType === "cdk" ? (record) => record.id?.value || record.customerId : (record) => record.ContactId; @@ -274,7 +254,7 @@ export function DmsCustomerSelector({ bodyshop, jobid }) { onSelect: (record) => { const key = dmsType === "rr" - ? record.CustomerId || record.customerId + ? record.custNo : dmsType === "cdk" ? record.id?.value || record.customerId : record.ContactId; diff --git a/client/src/components/dms-post-form/dms-post-form.component.jsx b/client/src/components/dms-post-form/dms-post-form.component.jsx index 7b4b60750..60e145aac 100644 --- a/client/src/components/dms-post-form/dms-post-form.component.jsx +++ b/client/src/components/dms-post-form/dms-post-form.component.jsx @@ -75,7 +75,7 @@ export function DmsPostForm({ bodyshop, socket, job, logsRef }) { if (dms === "rr") { wsssocket.emit("rr-export-job", { bodyshopId: bodyshop?.id || bodyshop?.bodyshopid || bodyshop?.uuid, - jobid: job.id, + jobId: job.id, job, txEnvelope: values }); diff --git a/client/src/pages/dms/dms.container.jsx b/client/src/pages/dms/dms.container.jsx index 409e52980..0a8282ccc 100644 --- a/client/src/pages/dms/dms.container.jsx +++ b/client/src/pages/dms/dms.container.jsx @@ -111,10 +111,11 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse }; const handleLogEvent = (payload) => setLogs((prev) => [...prev, payload]); + const handleExportSuccess = (payload) => { - notification.success({ message: t("jobs.successes.exported") }); + const jobId = payload?.jobId ?? payload; // RR sends object; legacy sends raw id notification.success({ message: t("jobs.successes.exported") }); insertAuditTrail({ - jobid: payload, + jobid: jobId, operation: AuditTrailMapping.jobexported(), type: "jobexported" }); @@ -128,6 +129,7 @@ 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); diff --git a/server.js b/server.js index b96eff093..4751d7d00 100644 --- a/server.js +++ b/server.js @@ -123,7 +123,6 @@ const applyRoutes = ({ app }) => { app.use("/payroll", require("./server/routes/payrollRoutes")); app.use("/sso", require("./server/routes/ssoRoutes")); app.use("/integrations", require("./server/routes/intergrationRoutes")); - app.use("/rr", require("./server/rr")); // Default route for forbidden access app.get("/", (req, res) => { diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 0ada3141d..dbcdff028 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2209,16 +2209,18 @@ exports.UPDATE_OLD_TRANSITION = `mutation UPDATE_OLD_TRANSITION($jobid: uuid!, $ exports.INSERT_NEW_TRANSITION = ( includeOldTransition -) => `mutation INSERT_NEW_TRANSITION($newTransition: transitions_insert_input!, ${includeOldTransition ? `$oldTransitionId: uuid!, $duration: numeric` : "" - }) { +) => `mutation INSERT_NEW_TRANSITION($newTransition: transitions_insert_input!, ${ + includeOldTransition ? `$oldTransitionId: uuid!, $duration: numeric` : "" +}) { insert_transitions_one(object: $newTransition) { id } - ${includeOldTransition - ? `update_transitions(where: {id: {_eq: $oldTransitionId}}, _set: {duration: $duration}) { + ${ + includeOldTransition + ? `update_transitions(where: {id: {_eq: $oldTransitionId}}, _set: {duration: $duration}) { affected_rows }` - : "" + : "" } }`; @@ -2908,6 +2910,8 @@ exports.GET_BODYSHOP_BY_ID = ` state notification_followers timezone + rr_dealerid + rr_configuration } } `; diff --git a/server/rr/index.js b/server/rr/index.js deleted file mode 100644 index 3853ca4dc..000000000 --- a/server/rr/index.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require("./rrRoutes"); diff --git a/server/rr/resolveRRConfigHttp.js b/server/rr/resolveRRConfigHttp.js deleted file mode 100644 index 42746d36e..000000000 --- a/server/rr/resolveRRConfigHttp.js +++ /dev/null @@ -1,32 +0,0 @@ -const { getRRConfigForBodyshop } = require("./rr-config"); -const { RrApiError } = require("./rr-error"); - -/** - * Extracts bodyshopId from body, job, or header and loads RR config. - * @returns {Promise<{ bodyshopId: string, config: any }>} - */ -async function resolveRRConfigHttp(req) { - const body = req?.body || {}; - - const fromBody = body.bodyshopId; - const fromJob = body.job && (body.job.shopid || body.job.bodyshopId); - const fromHeader = typeof req.get === "function" ? req.get("x-bodyshop-id") : undefined; - - const bodyshopId = fromBody || fromJob || fromHeader; - - if (!bodyshopId) { - throw new RrApiError( - "Missing bodyshopId (expected in body.bodyshopId, body.job.shopid/bodyshopId, or x-bodyshop-id header)", - "BAD_REQUEST" - ); - } - - const config = await getRRConfigForBodyshop(bodyshopId); - if (!config?.dealerNumber) { - throw new RrApiError(`RR config not found for bodyshopId=${bodyshopId} (missing dealerNumber)`, "NOT_CONFIGURED"); - } - - return { bodyshopId, config }; -} - -module.exports = { resolveRRConfigHttp }; diff --git a/server/rr/rr-client.js b/server/rr/rr-client.js deleted file mode 100644 index 66b51df95..000000000 --- a/server/rr/rr-client.js +++ /dev/null @@ -1,25 +0,0 @@ -const { RRClient } = require("./lib/index.cjs"); - -/** - * Build an RR client using env credentials. - * @param {{logger?: {debug?:Function,info?:Function,warn?:Function,error?:Function}}} opts - */ -function makeRRClient({ logger } = {}) { - const baseUrl = process.env.RR_BASE_URL; - const username = process.env.RR_USERNAME; - const password = process.env.RR_PASSWORD; - - if (!baseUrl || !username || !password) { - throw new Error("RR creds missing (RR_BASE_URL, RR_USERNAME, RR_PASSWORD)."); - } - - return new RRClient({ - baseUrl, - username, - password, - logger - // retries: { max: 3 }, // optional: override retry policy - }); -} - -module.exports = { makeRRClient }; diff --git a/server/rr/rr-config.js b/server/rr/rr-config.js index 9d1011cf4..0990c2955 100644 --- a/server/rr/rr-config.js +++ b/server/rr/rr-config.js @@ -1,63 +1,59 @@ +// server/rr/rr-config.js +// Build RR client configuration from bodyshop settings or env + +function requireString(v, name) { + const s = (v ?? "").toString().trim(); + if (!s || s.toLowerCase() === "undefined" || s.toLowerCase() === "null") { + throw new Error(`RR config missing: ${name}`); + } + return s; +} + /** - * Loads per-bodyshop RR routing from Hasura. - * Expected table fields (adapt if your schema differs): - * - bodyshops.id = $bodyshopId - * - bodyshops.rr_dealerid (string) - * - bodyshops.rr_configuration JSON { storeNumber?, branchNumber? } + * Extract RR connection + routing from a bodyshop record (preferred) + * Falls back to process.env for any missing bits. * - * Requires env: - * HASURA_GRAPHQL_ENDPOINT, HASURA_ADMIN_SECRET + * Bodyshop fields expected: + * - rr_dealerid -> dealerNumber + * - rr_configuration: { storeNumber, branchNumber } -> storeNumber, areaNumber + * + * Env fallbacks: + * RR_BASE_URL, RR_USERNAME, RR_PASSWORD, + * RR_DEALER_NUMBER, RR_STORE_NUMBER, RR_BRANCH_NUMBER */ -const { GraphQLClient, gql } = require("graphql-request"); +function getRRConfigFromBodyshop(bodyshop) { + const baseUrl = process.env.RR_BASE_URL; + const username = process.env.RR_USERNAME; + const password = process.env.RR_PASSWORD; -const HASURA_URL = process.env.HASURA_GRAPHQL_ENDPOINT || process.env.HASURA_URL; -const HASURA_SECRET = process.env.HASURA_ADMIN_SECRET || process.env.HASURA_GRAPHQL_ADMIN_SECRET; + // NOTE: your schema uses rr_dealerid and rr_configuration JSON + const dealerNumber = bodyshop?.rr_dealerid ?? process.env.RR_DEALER_NUMBER; -if (!HASURA_URL || !HASURA_SECRET) { - // Warn loudly at startup; you can hard fail if you prefer - console.warn("[RR] HASURA env not set (HASURA_GRAPHQL_ENDPOINT / HASURA_ADMIN_SECRET)."); + const bsCfg = bodyshop?.rr_configuration || {}; + const storeNumber = + bsCfg?.storeNumber ?? + bodyshop?.rr_store_number ?? // legacy fallback if present + process.env.RR_STORE_NUMBER; + + const areaNumber = + bsCfg?.branchNumber ?? + bsCfg?.areaNumber ?? // accept either key + bodyshop?.rr_branch_number ?? // legacy fallback if present + process.env.RR_BRANCH_NUMBER; + + return { + baseUrl: requireString(baseUrl, "baseUrl"), + username: requireString(username, "username"), + password: requireString(password, "password"), + routing: { + dealerNumber: requireString(String(dealerNumber), "routing.dealerNumber"), + storeNumber: requireString(String(storeNumber), "routing.storeNumber"), + areaNumber: requireString(String(areaNumber), "routing.areaNumber") + }, + // timeouts / retries can be adjusted here + timeoutMs: Number(process.env.RR_TIMEOUT_MS || 30000), + retries: { max: Number(process.env.RR_RETRIES_MAX || 2) } + }; } -const client = HASURA_URL - ? new GraphQLClient(HASURA_URL, { - headers: { "x-hasura-admin-secret": HASURA_SECRET } - }) - : null; - -const Q_BODYSHOP_RR = gql` - query RR_Config($id: uuid!) { - bodyshops_by_pk(id: $id) { - id - rr_dealerid - rr_configuration - } - } -`; - -/** - * @param {string} bodyshopId - * @returns {Promise<{dealerNumber:string, storeNumber?:string, areaNumber?:string}>} - */ -async function getRRConfigForBodyshop(bodyshopId) { - if (!client) throw new Error("Hasura client not configured."); - - const data = await client.request(Q_BODYSHOP_RR, { id: bodyshopId }); - const row = data?.bodyshops_by_pk; - if (!row) throw new Error(`Bodyshop not found: ${bodyshopId}`); - - const dealerNumber = row.rr_dealerid; - - const cfg = row.rr_configuration || {}; - - if (!dealerNumber) { - throw new Error(`RR not configured for bodyshop ${bodyshopId} (missing rr_dealerid).`); - } - - // The RR client expects "areaNumber" (Rome "branch") - const storeNumber = cfg.storeNumber || cfg.store_no || cfg.store || null; - const areaNumber = cfg.branchNumber || cfg.branch_no || cfg.branch || null; - - return { dealerNumber, storeNumber, areaNumber }; -} - -module.exports = { getRRConfigForBodyshop }; +module.exports = { getRRConfigFromBodyshop }; diff --git a/server/rr/rr-customer.js b/server/rr/rr-customer.js deleted file mode 100644 index 34e7523f1..000000000 --- a/server/rr/rr-customer.js +++ /dev/null @@ -1,17 +0,0 @@ -const { withClient } = require("../rr/withClient"); - -async function insertCustomer({ bodyshopId, payload }) { - return withClient(bodyshopId, async (client, routing) => { - const res = await client.insertCustomer(payload, { routing }); - return res; - }); -} - -async function updateCustomer({ bodyshopId, payload }) { - return withClient(bodyshopId, async (client, routing) => { - const res = await client.updateCustomer(payload, { routing }); - return res; - }); -} - -module.exports = { insertCustomer, updateCustomer }; diff --git a/server/rr/rr-customers.js b/server/rr/rr-customers.js new file mode 100644 index 000000000..724827eb4 --- /dev/null +++ b/server/rr/rr-customers.js @@ -0,0 +1,112 @@ +// server/rr/rr-customers.js +// Minimal RR customer create helper + +const { RRClient } = require("./lib/index.cjs"); +const { getRRConfigFromBodyshop } = require("./rr-config"); +const RRLogger = require("./rr-logger"); + +// Build client + opts from bodyshop +function buildClientAndOpts(bodyshop) { + const cfg = getRRConfigFromBodyshop(bodyshop); + const client = new RRClient({ + baseUrl: cfg.baseUrl, + username: cfg.username, + password: cfg.password, + timeoutMs: cfg.timeoutMs, + retries: cfg.retries + }); + const opts = { + routing: cfg.routing, + envelope: { + sender: { + component: "Rome", + task: "CVC", + referenceId: "CreateCustomer", + creator: "RCI", + senderName: "RCI" + } + } + }; + return { client, opts }; +} + +// minimal field extraction +function digitsOnly(s) { + return String(s || "").replace(/[^\d]/g, ""); +} + +function buildCustomerPayloadFromJob(job, overrides = {}) { + const firstName = overrides.firstName ?? job?.ownr_fn ?? job?.customer?.first_name ?? ""; + const lastName = overrides.lastName ?? job?.ownr_ln ?? job?.customer?.last_name ?? ""; + const company = overrides.company ?? job?.ownr_co_nm ?? job?.customer?.company_name ?? ""; + + // Prefer owner phone; fall back to customer phones + const phone = + overrides.phone ?? + job?.ownr_ph1 ?? + job?.customer?.mobile ?? + job?.customer?.home_phone ?? + job?.customer?.phone ?? + ""; + + const payload = { + // These keys follow the RR client’s customers op conventions (the lib normalizes case) + firstName: firstName || undefined, + lastName: lastName || undefined, + companyName: company || undefined, + phone: digitsOnly(phone) || undefined, + email: overrides.email || job?.ownr_ea || job?.customer?.email || undefined, + address: { + line1: overrides.addressLine1 ?? job?.ownr_addr1 ?? job?.customer?.address_line1 ?? undefined, + line2: overrides.addressLine2 ?? job?.ownr_addr2 ?? job?.customer?.address_line2 ?? undefined, + city: overrides.city ?? job?.ownr_city ?? job?.customer?.city ?? undefined, + state: overrides.state ?? job?.ownr_st ?? job?.customer?.state ?? job?.customer?.province ?? undefined, + postalCode: overrides.postalCode ?? job?.ownr_zip ?? job?.customer?.postal_code ?? undefined, + country: overrides.country ?? job?.ownr_ctry ?? job?.customer?.country ?? "CA" + } + }; + + return payload; +} + +/** + * Create a customer in RR and return { custNo, raw }. + * Tries common op names to stay compatible with the generated client. + */ +async function createRRCustomer({ bodyshop, job, overrides = {}, socket }) { + const log = RRLogger(socket, { ns: "rr" }); + const { client, opts } = buildClientAndOpts(bodyshop); + const payload = buildCustomerPayloadFromJob(job, overrides); + + let res; + // Try common method names; your lib exposes one of these. + if (typeof client.createCustomer === "function") { + res = await client.createCustomer(payload, opts); + } else if (typeof client.insertCustomer === "function") { + res = await client.insertCustomer(payload, opts); + } else if (client.customers && typeof client.customers.create === "function") { + res = await client.customers.create(payload, opts); + } else { + throw new Error("RR customer create operation not found in client"); + } + + const data = res?.data ?? res; + const custNo = + data?.custNo ?? + data?.CustNo ?? + data?.customerNo ?? + data?.CustomerNo ?? + data?.customer?.custNo ?? + data?.Customer?.CustNo; + + if (!custNo) { + log("error", "RR create customer returned no custNo", { data }); + throw new Error("RR create customer returned no custNo"); + } + + return { custNo, raw: data }; +} + +module.exports = { + createRRCustomer +}; diff --git a/server/rr/rr-error.js b/server/rr/rr-error.js deleted file mode 100644 index 6f345f9b7..000000000 --- a/server/rr/rr-error.js +++ /dev/null @@ -1,11 +0,0 @@ -class RrApiError extends Error { - constructor(message, code, meta) { - super(message); - this.name = "RrApiError"; - this.code = code || "RR_API_ERROR"; - if (meta) this.meta = meta; - Error.captureStackTrace?.(this, RrApiError); - } -} - -module.exports = { RrApiError }; diff --git a/server/rr/rr-job-export.js b/server/rr/rr-job-export.js index 3a9922bd7..2a3d2b865 100644 --- a/server/rr/rr-job-export.js +++ b/server/rr/rr-job-export.js @@ -1,67 +1,28 @@ -const { withClient } = require("./withClient"); +const { buildRRRepairOrderPayload } = require("./rr-job-helpers"); +const { buildClientAndOpts } = require("./rr-lookup"); -async function exportJobToRR({ bodyshopId, job, logger }) { - return withClient(bodyshopId, logger, async (client, routing) => { - // 1) Upsert Customer - const custPayload = mapJobToCustomer(job); - const custRes = job.customer?.nameRecId - ? await client.updateCustomer(custPayload, { routing }) - : await client.insertCustomer(custPayload, { routing }); +async function exportJobToRR(args) { + const { bodyshop, job, selectedCustomer, advisorNo, existing } = args; - const customerNo = custRes?.data?.dmsRecKey || job.customer?.customerNo; - if (!customerNo) throw new Error("Failed to resolve customerNo from RR response."); + // Build client + opts (opts carries routing) + const { client, opts } = buildClientAndOpts(bodyshop); - // 2) Ensure Service Vehicle (optional, if VIN present) - if (job?.vehicle?.vin) { - await client.insertServiceVehicle( - { - vin: job.vehicle.vin, - vehicleServInfo: { customerNo } - }, - { routing } - ); - } + const payload = buildRRRepairOrderPayload({ job, selectedCustomer, advisorNo }); - // 3) Create RO - const roHeader = { - customerNo, - departmentType: "B", - vin: job?.vehicle?.vin, - outsdRoNo: job?.roExternal || job?.id, - advisorNo: job?.advisorNo, - mileageIn: job?.mileageIn - }; + let rrRes; + if (existing?.dmsRepairOrderId) { + rrRes = await client.updateRepairOrder({ ...payload, dmsRepairOrderId: existing.dmsRepairOrderId }, opts); + } else { + rrRes = await client.createRepairOrder(payload, opts); + } - const roBody = mapJobToRO(job); // extend if you want lines/tax/etc - const roRes = await client.createRepairOrder({ ...roHeader, ...roBody }, { routing }); - return roRes?.data; - }); -} - -function mapJobToCustomer(job) { - const c = job?.customer || {}; return { - nameRecId: c.nameRecId, - firstName: c.firstName || c.given_name, - lastName: c.lastName || c.family_name, - phone: c.phone || c.mobile, - email: c.email, - address: { - line1: c.address1, - line2: c.address2, - city: c.city, - state: c.province || c.state, - postalCode: c.postal || c.zip - } - }; -} - -function mapJobToRO(job) { - return { - // rolabor: [...], - // roparts: [...], - // estimate: {...}, - // tax: {...} + success: rrRes?.success === true, + data: rrRes?.data || null, + roStatus: rrRes?.data?.roStatus || null, + statusBlocks: rrRes?.statusBlocks || [], + xml: rrRes?.xml, + parsed: rrRes?.parsed }; } diff --git a/server/rr/rr-job-helpers.js b/server/rr/rr-job-helpers.js index 5c8cc5e5f..85d678e8c 100644 --- a/server/rr/rr-job-helpers.js +++ b/server/rr/rr-job-helpers.js @@ -1,54 +1,76 @@ -// server/rr/rr-job-helpers.js const { GraphQLClient } = require("graphql-request"); const queries = require("../graphql-client/queries"); -const { combinedSearch } = require("./rr-lookup"); - -/** Namespace the job transaction data for RR */ -const getTransactionType = (jobid) => `rr:${jobid}`; /** - * QueryJobData — mirrors your Fortellis/CDK helpers; fetches job with all export details. - * Requires the caller's token (we read from the socket handshake). + * Query job + related entities. + * Supports { socket } (GraphQL) and/or { redisHelpers } (cache/fetch). */ -async function QueryJobData({ socket, jobid }) { - const endpoint = process.env.GRAPHQL_ENDPOINT || process.env.HASURA_GRAPHQL_ENDPOINT || process.env.HASURA_URL; - if (!endpoint) throw new Error("GRAPHQL endpoint not configured"); +async function QueryJobData(ctx = {}, jobId) { + if (!jobId) throw new Error("jobId required"); - const client = new GraphQLClient(endpoint, {}); - const token = (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token); - if (!token) throw new Error("Missing auth token for QueryJobData"); + const { redisHelpers, socket } = ctx; - const res = await client - .setHeaders({ Authorization: `Bearer ${token}` }) - .request(queries.QUERY_JOBS_FOR_CDK_EXPORT, { id: jobid }); + if (redisHelpers) { + if (typeof redisHelpers.getJobFromCache === "function") { + try { + const hit = await redisHelpers.getJobFromCache(jobId); + if (hit) return hit; + } catch {} + } + if (typeof redisHelpers.fetchJobById === "function") { + const full = await redisHelpers.fetchJobById(jobId); + if (full) return full; + } + } - return res?.jobs_by_pk || null; + if (socket) { + const endpoint = process.env.GRAPHQL_ENDPOINT; + if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured"); + + const token = (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token); + + if (!token) throw new Error("Missing bearer token on socket for GraphQL fetch"); + + const client = new GraphQLClient(endpoint, {}); + const resp = await client + .setHeaders({ Authorization: `Bearer ${token}` }) + .request(queries.QUERY_JOBS_FOR_CDK_EXPORT, { id: jobId }); + + const job = resp?.jobs_by_pk; + if (job) return job; + throw new Error("QueryJobData: job not found via GraphQL"); + } + + throw new Error("QueryJobData: no available method to load job (need socket or redisHelpers)"); } /** - * QueryDMSVehicleById — for RR we don't have a direct "vehicle by id" read, - * so we approximate with CombinedSearch by VIN and return the first hit. + * Build RR create/update RO payload. + * Prefers selectedVehicle.vin (if present) over job VIN. */ -async function QueryDMSVehicleById({ bodyshopId, vin }) { - if (!vin) return null; - const res = await combinedSearch({ bodyshopId, kind: "vin", vin }); - // RR lib returns { success, data: CombinedSearchBlock[] } - const blocks = res?.data || res || []; - const first = Array.isArray(blocks) && blocks.length ? blocks[0] : null; - if (!first) return null; +function buildRRRepairOrderPayload({ job, selectedCustomer, selectedVehicle, advisorNo }) { + const custNo = + selectedCustomer?.custNo || + selectedCustomer?.customerNo || + selectedCustomer?.CustNo || + selectedCustomer?.CustomerNo; - const vehicle = first?.Vehicle || first?.vehicle || first?.Veh || null; - const customer = first?.Customer || first?.customer || first?.Cust || null; + if (!custNo) throw new Error("No RR customer selected (custNo missing)"); + + const vin = selectedVehicle?.vin || job?.vehicle?.vin || job?.v_vin || job?.vehicle_vin; + + if (!vin) throw new Error("No VIN available (select or create a vehicle)"); return { + repairOrderNumber: String(job?.job_number || job?.id), + deptType: "B", vin, - vehicle, - customer + custNo, + advNo: advisorNo || undefined }; } module.exports = { QueryJobData, - QueryDMSVehicleById, - getTransactionType + buildRRRepairOrderPayload }; diff --git a/server/rr/rr-logger.js b/server/rr/rr-logger.js index 0fdd8f187..720021361 100644 --- a/server/rr/rr-logger.js +++ b/server/rr/rr-logger.js @@ -1,14 +1,70 @@ -const logger = require("../utils/logger"); +// server/rr/rr-logger.js +// Robust socket + server logger for RR flows (no more [object Object] in UI) -function RRLogger(socket) { - return function log(level = "info", message = "", ctx = {}) { - // Console - const fn = logger.logger[level] || logger.log; - fn(`[RR] ${new Date().toISOString()} [${level.toUpperCase()}] ${message}`, ctx); +"use strict"; + +const util = require("util"); +const appLogger = require("../utils/logger"); + +function RRLogger(socket, baseCtx = {}) { + const levels = new Set(["error", "warn", "info", "http", "verbose", "debug", "silly"]); + + const safeString = (v) => { + if (v instanceof Error) return v.message; + if (typeof v === "string") return v; try { - socket?.emit?.("RR:LOG", { level, message, ctx, ts: Date.now() }); + return JSON.stringify(v); } catch { - /* ignore */ + return util.inspect(v, { depth: 2, maxArrayLength: 50 }); + } + }; + + return function log(levelOrMsg, msgOrCtx, ctx) { + let level = "info"; + let message = undefined; + let meta = {}; + + if (typeof levelOrMsg === "string" && levels.has(levelOrMsg)) { + level = levelOrMsg; + message = msgOrCtx; + meta = ctx || {}; + } else { + message = levelOrMsg; + meta = msgOrCtx || {}; + } + + // Prepare console line + metadata + const emitError = message instanceof Error; + if (emitError) { + meta.err = { + name: message.name, + message: message.message, + stack: message.stack + }; + message = message.message; + if (level === "info") level = "error"; + } + + const messageString = safeString(message); + const line = `[RR] ${new Date().toISOString()} [${String(level).toUpperCase()}] ${messageString}`; + const loggerFn = appLogger?.logger?.[level] || appLogger?.logger?.info || ((...args) => console.log(...args)); + + loggerFn(line, { ...baseCtx, ...meta }); + + // Always emit a STRING for `message` to sockets to avoid React crashes + // If the original message was an object, include it in `details` + const details = message && typeof message !== "string" && !emitError ? message : undefined; + + try { + socket?.emit?.("rr-log-event", { + level, + message: messageString, // <-- normalized string for UI + ctx: { ...baseCtx, ...meta }, + ...(details ? { details } : {}), + ts: Date.now() + }); + } catch { + /* ignore socket emission errors */ } }; } diff --git a/server/rr/rr-lookup.js b/server/rr/rr-lookup.js index ff18f0552..caceca29f 100644 --- a/server/rr/rr-lookup.js +++ b/server/rr/rr-lookup.js @@ -1,24 +1,145 @@ -const { withClient } = require("./withClient"); +// server/rr/rr-lookup.js +// Reynolds & Reynolds lookup helpers that adapt our bodyshop record to the RR client -async function getAdvisors({ bodyshopId, ...criteria }) { - return withClient(bodyshopId, async (client, routing) => { - const res = await client.getAdvisors(criteria, { routing }); - return res; +const { RRClient } = require("./lib/index.cjs"); +const { getRRConfigFromBodyshop } = require("./rr-config"); + +/** + * Build an RR client + common opts from a bodyshop row + */ +function buildClientAndOpts(bodyshop) { + const cfg = getRRConfigFromBodyshop(bodyshop); + + const client = new RRClient({ + baseUrl: cfg.baseUrl, + username: cfg.username, + password: cfg.password, + timeoutMs: cfg.timeoutMs, + retries: cfg.retries + // optional debug logger already inside lib; leave defaults }); + + // Common CallOptions for all ops; routing is CRITICAL for Destination block + const opts = { + routing: cfg.routing, + envelope: { + // You can override these per-call if needed + sender: { + component: "Rome", + task: "CVC", + referenceId: "Query", + creator: "RCI", + senderName: "RCI" + } + // bodId/creationDateTime auto-filled by the client if omitted + } + }; + + return { client, opts }; } -async function getParts({ bodyshopId, ...criteria }) { - return withClient(bodyshopId, async (client, routing) => { - const res = await client.getParts(criteria, { routing }); - return res; - }); +/** + * Normalize the combined-search arguments into the RR shape. + * We infer `kind` if not provided, based on the first detectable field. + */ +function toCombinedSearchPayload(args = {}) { + const q = { ...args }; + let kind = (q.kind || "").toString().trim().toLowerCase(); + + if (!kind) { + if (q.phone) kind = "phone"; + else if (q.license) kind = "license"; + else if (q.vin) kind = "vin"; + else if (q.nameRecId || q.custId) kind = "nameRecId"; + else if (q.name && (q.name.fname || q.name.lname || q.name.mname || q.name.name)) kind = "name"; + else if (q.stkNo || q.stock) kind = "stkNo"; + } + + // Map loose aliases into the RR builder’s expected fields + const payload = { maxResults: q.maxResults || q.maxRecs || 50, kind }; + + switch (kind) { + case "phone": + payload.phone = q.phone; + break; + case "license": + payload.license = q.license; + break; + case "vin": + payload.vin = q.vin; + break; + case "namerecid": + payload.nameRecId = q.nameRecId || q.custId; + break; + case "name": + payload.name = q.name; // { fname, lname, mname } or { name } + break; + case "stkno": + payload.stkNo = q.stkNo || q.stock; + break; + default: + // Let the RR builder throw the canonical “Unsupported CombinedSearch kind” + payload.kind = q.kind; // may be undefined; RR lib will validate + } + + // Optional vehicle narrowing; the RR builder defaults to ANY/ANY/ANY if omitted + if (q.make || q.model || q.year) { + payload.make = q.make; + payload.model = q.model; + payload.year = q.year; + } + + return payload; } -async function combinedSearch({ bodyshopId, ...query }) { - return withClient(bodyshopId, async (client, routing) => { - const res = await client.combinedSearch(query, { routing }); - return res; - }); +/** + * Combined customer/service/vehicle search + * @param bodyshop - bodyshop row (must include rr_dealerid & rr_configuration with store/branch) + * @param args - search inputs (phone | license | vin | nameRecId | name | stkNo) + */ +async function rrCombinedSearch(bodyshop, args = {}) { + const { client, opts } = buildClientAndOpts(bodyshop); + const payload = toCombinedSearchPayload(args); + const res = await client.combinedSearch(payload, opts); + return res?.data ?? res; // lib returns { success, data, ... } } -module.exports = { getAdvisors, getParts, combinedSearch }; +/** + * Advisors lookup + * @param bodyshop + * @param args - { department: 'B'|'S'|'P'|string, advisorNumber?: string } + */ +async function rrGetAdvisors(bodyshop, args = {}) { + const { client, opts } = buildClientAndOpts(bodyshop); + // Allow friendly department values + const dep = (args.department || "").toString().toUpperCase(); + const department = + dep === "BODY" || dep === "BODYSHOP" ? "B" : dep === "SERVICE" ? "S" : dep === "PARTS" ? "P" : dep || "B"; + + const payload = { + department, + advisorNumber: args.advisorNumber ? String(args.advisorNumber) : undefined + }; + + const res = await client.getAdvisors(payload, opts); + return res?.data ?? res; +} + +/** + * Parts on an internal RO + * @param bodyshop + * @param args - { roNumber: string } (ERA/DMS internal RO number) + */ +async function rrGetParts(bodyshop, args = {}) { + const { client, opts } = buildClientAndOpts(bodyshop); + const payload = { roNumber: String(args.roNumber || "").trim() }; + const res = await client.getParts(payload, opts); + return res?.data ?? res; +} + +module.exports = { + rrCombinedSearch, + rrGetAdvisors, + rrGetParts, + buildClientAndOpts +}; diff --git a/server/rr/rr-repair-orders.js b/server/rr/rr-repair-orders.js deleted file mode 100644 index a89040987..000000000 --- a/server/rr/rr-repair-orders.js +++ /dev/null @@ -1,17 +0,0 @@ -const { withClient } = require("./withClient"); - -async function createRepairOrder({ bodyshopId, payload }) { - return withClient(bodyshopId, async (client, routing) => { - const res = await client.createRepairOrder(payload, { routing }); - return res; - }); -} - -async function updateRepairOrder({ bodyshopId, payload }) { - return withClient(bodyshopId, async (client, routing) => { - const res = await client.updateRepairOrder(payload, { routing }); - return res; - }); -} - -module.exports = { createRepairOrder, updateRepairOrder }; diff --git a/server/rr/rr-selected-customer.js b/server/rr/rr-selected-customer.js deleted file mode 100644 index d4b707e51..000000000 --- a/server/rr/rr-selected-customer.js +++ /dev/null @@ -1,100 +0,0 @@ -// server/rr/rr-selected-customer.js -const RRLogger = require("./rr-logger"); -const { insertCustomer, updateCustomer } = require("./rr-customer"); -const { withClient } = require("./withClient"); -const { QueryJobData, getTransactionType } = require("./rr-job-helpers"); - -/** - * Selects/creates/updates the RR customer for a job and persists the choice in the session tx. - * - If selectedCustomerId is given: cache and return. - * - Else: upsert from the job's current customer data, cache resulting dmsRecKey. - */ -async function SelectedCustomer({ socket, jobid, bodyshopId, selectedCustomerId, redisHelpers }) { - const log = RRLogger(socket); - - // 1) Load JobData (we'll also use it for bodyshopId if missing) - const JobData = await QueryJobData({ socket, jobid }); - const resolvedBodyshopId = bodyshopId || JobData?.bodyshop?.id; - if (!resolvedBodyshopId) throw new Error("Unable to resolve bodyshopId for RR SelectedCustomer"); - - const txKey = getTransactionType(jobid); - const { setSessionTransactionData, getSessionTransactionData } = redisHelpers || {}; - const current = (await getSessionTransactionData?.(txKey)) || {}; - - // 2) If the UI already chose a DMS customer, just persist it - if (selectedCustomerId) { - await setSessionTransactionData?.(txKey, { - ...current, - selectedCustomerId - }); - log("info", "RR SelectedCustomer: using provided selectedCustomerId", { selectedCustomerId, jobid }); - return { selectedCustomerId }; - } - - // 3) Otherwise, upsert based on job's customer info (fallback), and cache the new id. - const j = JobData || {}; - const c = j.customer || {}; - - if (!c && !j?.vehicle?.vin) { - log("warn", "RR SelectedCustomer: no customer on job and no VIN; nothing to do", { jobid }); - return { selectedCustomerId: null }; - } - - // Upsert customer: prefer update if we have a NameRecId; else insert. - const customerPayload = mapJobCustomerToRR(c); - const upsert = c?.nameRecId - ? await updateCustomer({ bodyshopId: resolvedBodyshopId, payload: customerPayload }) - : await insertCustomer({ bodyshopId: resolvedBodyshopId, payload: customerPayload }); - - const dmsRecKey = - upsert?.data?.dmsRecKey || - upsert?.data?.DMSRecKey || - upsert?.data?.dmsRecKeyId || - c?.customerNo || - c?.nameRecId || - null; - - // Optionally ensure a ServiceVehicle record exists when VIN present (best effort). - if (j?.vehicle?.vin) { - try { - await withClient(resolvedBodyshopId, async (client, routing) => { - await client.insertServiceVehicle( - { vin: j.vehicle.vin, vehicleServInfo: { customerNo: dmsRecKey } }, - { routing } - ); - }); - log("info", "RR SelectedCustomer: ensured ServiceVehicle for VIN", { vin: j.vehicle.vin, dmsRecKey }); - } catch (e) { - log("warn", `RR SelectedCustomer: insertServiceVehicle skipped (${e.message})`, { vin: j?.vehicle?.vin }); - } - } - - // Save in session tx - await setSessionTransactionData?.(txKey, { - ...current, - selectedCustomerId: dmsRecKey - }); - - log("info", "RR SelectedCustomer: upsert complete", { dmsRecKey, jobid }); - return { selectedCustomerId: dmsRecKey }; -} - -function mapJobCustomerToRR(c = {}) { - // Mirrors the mapping used by rr-job-export (kept local to avoid cross-module exports) - return { - nameRecId: c.nameRecId, // for update path - firstName: c.firstName || c.given_name, - lastName: c.lastName || c.family_name || c.last_name, - phone: c.phone || c.mobile, - email: c.email, - address: { - line1: c.address1 || c.address || c.street, - line2: c.address2 || "", - city: c.city, - state: c.province || c.state, - postalCode: c.postal || c.zip - } - }; -} - -module.exports = { SelectedCustomer }; diff --git a/server/rr/rrRoutes.js b/server/rr/rrRoutes.js deleted file mode 100644 index 84dad2810..000000000 --- a/server/rr/rrRoutes.js +++ /dev/null @@ -1,173 +0,0 @@ -// server/rr/rrRoutes.js -const express = require("express"); -const router = express.Router(); - -const RRLogger = require("./rr-logger"); -const { RrApiError } = require("./rr-error"); -const { getRRConfigForBodyshop } = require("./rr-config"); -const lookupApi = require("./rr-lookup"); -const { insertCustomer, updateCustomer } = require("./rr-customer"); -const { exportJobToRR } = require("./rr-job-export"); -const { SelectedCustomer } = require("./rr-selected-customer"); -const { QueryJobData } = require("./rr-job-helpers"); - -// --- helpers & middleware (kept local for this router) --- -function socketOf(req) { - // attach a minimal "socket-like" emitter for logger compatibility - return { - emit: () => { - // - }, - handshake: { auth: { token: req?.headers?.authorization?.replace(/^Bearer\s+/i, "") } }, - user: req?.user - }; -} - -function ok(res, payload) { - return res.status(200).json(payload || { ok: true }); -} -function fail(res, e) { - const code = e?.code === "BAD_REQUEST" ? 400 : e?.code === "NOT_CONFIGURED" ? 412 : 500; - return res.status(code).json({ error: e?.message || String(e), code: e?.code || "RR_API_ERROR" }); -} - -function requireBodyshopId(req) { - const body = req?.body || {}; - const fromBody = body.bodyshopId; - const fromJob = body.job && (body.job.shopid || body.job.bodyshopId); - const fromHeader = typeof req.get === "function" ? req.get("x-bodyshop-id") : undefined; - const bodyshopId = fromBody || fromJob || fromHeader; - if (!bodyshopId) { - throw new RrApiError( - "Missing bodyshopId (expected in body.bodyshopId, body.job.shopid/bodyshopId, or x-bodyshop-id header)", - "BAD_REQUEST" - ); - } - return bodyshopId; -} - -// --- sanity/config checks --- -router.get("/rr/config", async (req, res) => { - try { - const bodyshopId = requireBodyshopId(req); - const cfg = await getRRConfigForBodyshop(bodyshopId); - return ok(res, { data: cfg }); - } catch (e) { - return fail(res, e); - } -}); - -// --- lookups --- -router.post("/rr/lookup/advisors", async (req, res) => { - try { - const bodyshopId = requireBodyshopId(req); - const data = await lookupApi.getAdvisors({ bodyshopId, ...(req.body || {}) }); - return ok(res, { data }); - } catch (e) { - return fail(res, e); - } -}); - -router.post("/rr/lookup/parts", async (req, res) => { - try { - const bodyshopId = requireBodyshopId(req); - const data = await lookupApi.getParts({ bodyshopId, ...(req.body || {}) }); - return ok(res, { data }); - } catch (e) { - return fail(res, e); - } -}); - -router.post("/rr/combined-search", async (req, res) => { - try { - const bodyshopId = requireBodyshopId(req); - const data = await lookupApi.combinedSearch({ bodyshopId, ...(req.body || {}) }); - return ok(res, { data }); - } catch (e) { - return fail(res, e); - } -}); - -// --- customers (basic insert/update) --- -router.post("/rr/customer/insert", async (req, res) => { - try { - const bodyshopId = requireBodyshopId(req); - const data = await insertCustomer({ bodyshopId, payload: req.body }); - return ok(res, { data }); - } catch (e) { - return fail(res, e); - } -}); - -router.post("/rr/customer/update", async (req, res) => { - try { - const bodyshopId = requireBodyshopId(req); - const data = await updateCustomer({ bodyshopId, payload: req.body }); - return ok(res, { data }); - } catch (e) { - return fail(res, e); - } -}); - -/** - * NEW: set or create the selected RR customer for a given job - * body: { jobid: uuid, selectedCustomerId?: string, bodyshopId?: uuid } - */ -router.post("/rr/customer/selected", async (req, res) => { - const socket = socketOf(req); - const logger = (level, message, ctx) => RRLogger(socket)(level, message, ctx); - try { - const { jobid, selectedCustomerId } = req.body || {}; - if (!jobid) throw new RrApiError("Missing 'jobid' in body", "BAD_REQUEST"); - - // We allow bodyshopId in the body, but will resolve from JobData if not present. - const bodyshopId = req.body?.bodyshopId || null; - - const result = await SelectedCustomer({ - socket, - jobid, - bodyshopId, - selectedCustomerId, - redisHelpers: req.redisHelpers - }); - - logger("info", "RR /rr/customer/selected success", { jobid, selectedCustomerId: result.selectedCustomerId }); - return ok(res, { data: result }); - } catch (e) { - RRLogger(socket)("error", "RR /rr/customer/selected failed", { error: e.message }); - return fail(res, e); - } -}); - -/** - * NEW: fetch canonical JobData used for DMS exports (mirrors Fortellis/CDK QueryJobData) - * body: { jobid: uuid } - */ -router.post("/rr/job/query", async (req, res) => { - try { - const { jobid } = req.body || {}; - if (!jobid) throw new RrApiError("Missing 'jobid' in body", "BAD_REQUEST"); - const data = await QueryJobData({ socket: socketOf(req), jobid }); - return ok(res, { data }); - } catch (e) { - return fail(res, e); - } -}); - -// --- export orchestrator --- -router.post("/rr/export/job", async (req, res) => { - const socket = socketOf(req); - const logger = (level, message, ctx) => RRLogger(socket)(level, message, ctx); - try { - const bodyshopId = requireBodyshopId(req); - const { job, options = {} } = req.body || {}; - if (!job) throw new RrApiError("Missing 'job' in request body", "BAD_REQUEST"); - const data = await exportJobToRR({ bodyshopId, job, logger, ...options }); - return ok(res, { data }); - } catch (e) { - RRLogger(socket)("error", "RR /rr/export/job failed", { error: e.message }); - return fail(res, e); - } -}); - -module.exports = router; diff --git a/server/rr/withClient.js b/server/rr/withClient.js deleted file mode 100644 index 2b5b64ba9..000000000 --- a/server/rr/withClient.js +++ /dev/null @@ -1,11 +0,0 @@ -const { getRRConfigForBodyshop } = require("./rr-config"); -const { makeRRClient } = require("./rr-client"); -const logger = require("../utils/logger"); - -async function withClient(bodyshopId, fn) { - const routing = await getRRConfigForBodyshop(bodyshopId); - const client = makeRRClient({ logger }); - return fn(client, routing); -} - -module.exports = { withClient }; diff --git a/server/web-sockets/redisSocketEvents.js b/server/web-sockets/redisSocketEvents.js index 8ace10c05..6f74ebfd6 100644 --- a/server/web-sockets/redisSocketEvents.js +++ b/server/web-sockets/redisSocketEvents.js @@ -1,16 +1,12 @@ const { admin } = require("../firebase/firebase-handler"); const FortellisLogger = require("../fortellis/fortellis-logger"); -const RRLogger = require("../rr/rr-logger"); const { FortellisJobExport, FortellisSelectedCustomer } = require("../fortellis/fortellis"); const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default; +const registerRREvents = require("./rr-register-socket-events"); -const lookupApi = require("../rr/rr-lookup"); -const { SelectedCustomer } = require("../rr/rr-selected-customer"); -const { QueryJobData } = require("../rr/rr-job-helpers"); - -const redisSocketEvents = ({ - io, - redisHelpers: { +const redisSocketEvents = ({ io, redisHelpers, ioHelpers, logger }) => { + // Destructure helpers locally, but keep full objects available for downstream modules + const { setSessionData, getSessionData, addUserSocketMapping, @@ -20,10 +16,10 @@ const redisSocketEvents = ({ setSessionTransactionData, getSessionTransactionData, clearSessionTransactionData - }, - ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }, - logger -}) => { + } = redisHelpers; + + const { getBodyshopRoom, getBodyshopConversationRoom } = ioHelpers; + // Logging helper functions const createLogEvent = (socket, level, message) => { logger.log("ioredis-log-event", level, socket?.user?.email, null, { wsmessage: message }); @@ -53,6 +49,15 @@ const redisSocketEvents = ({ socket.handshake.auth.token = token; socket.handshake.auth.bodyshopId = bodyshopId; } + + // NEW: seed a base session for this socket so downstream handlers can read it + await setSessionData(socket.id, { + bodyshopId, + email: user.email, + uid: user.user_id || user.uid, + seededAt: Date.now() + }); + await addUserSocketMapping(user.email, socket.id, bodyshopId); next(); } catch (error) { @@ -91,6 +96,15 @@ const redisSocketEvents = ({ socket.handshake.auth.token = token; socket.handshake.auth.bodyshopId = bodyshopId; } + + // NEW: refresh (or create) the base session with the latest info + await setSessionData(socket.id, { + bodyshopId, + email: user.email, + uid: user.user_id || user.uid, + refreshedAt: Date.now() + }); + await refreshUserSocketTTL(user.email, bodyshopId); socket.emit("token-updated", { success: true }); } catch (error) { @@ -147,6 +161,10 @@ const redisSocketEvents = ({ if (socket.user?.email) { await removeUserSocketMapping(socket.user.email, socket.id); } + // Optional: clear transactional session + try { + await clearSessionTransactionData(socket.id); + } catch {} // Leave all rooms except the default room (socket.id) const rooms = Array.from(socket.rooms).filter((room) => room !== socket.id); for (const room of rooms) { @@ -251,7 +269,7 @@ const redisSocketEvents = ({ }); }; - //Fortellis/CDK Handlers + // Fortellis/CDK Handlers const registerFortellisEvents = (socket) => { socket.on("fortellis-export-job", async ({ jobid, txEnvelope }) => { try { @@ -280,6 +298,7 @@ const redisSocketEvents = ({ }); } }); + socket.on("fortellis-selected-customer", async ({ jobid, selectedCustomerId }) => { try { await FortellisSelectedCustomer({ @@ -307,6 +326,7 @@ const redisSocketEvents = ({ }); } }); + socket.on("fortellis-calculate-allocations", async (jobid, callback) => { try { const allocations = await CdkCalculateAllocations(socket, jobid); @@ -342,94 +362,6 @@ const redisSocketEvents = ({ }); }; - // Reynolds & Reynolds socket events (uses new client-backed ops) - function registerRREvents(socket) { - const logger = require("../utils/logger"); - const log = RRLogger(socket); - const { - redisHelpers // { setSessionData, getSessionData, ... setSessionTransactionData, getSessionTransactionData } - } = require("../utils/ioHelpers").getHelpers?.() || { redisHelpers: {} }; - - const resolveJobId = (maybeId, packet, fallback) => maybeId || packet?.jobid || fallback; - - // Lookups - socket.on("rr-get-advisors", async (params = {}, cb) => { - try { - const bodyshopId = params.bodyshopId || socket?.user?.bodyshopid; - const res = await lookupApi.getAdvisors({ bodyshopId, ...(params || {}) }); - cb?.({ data: res?.data ?? res }); - } catch (e) { - log("error", `RR get advisors error: ${e.message}`); - cb?.({ error: e.message }); - } - }); - - socket.on("rr-get-parts", async (params = {}, cb) => { - try { - const bodyshopId = params.bodyshopId || socket?.user?.bodyshopid; - const res = await lookupApi.getParts({ bodyshopId, ...(params || {}) }); - cb?.({ data: res?.data ?? res }); - } catch (e) { - log("error", `RR get parts error: ${e.message}`); - cb?.({ error: e.message }); - } - }); - - /** - * NEW: QueryJobData — return the canonical job payload used for DMS exports - * payload: { jobid } - */ - socket.on("rr-query-job-data", async ({ jobid } = {}, cb) => { - try { - const resolvedJobId = resolveJobId(jobid, { jobid }, null); - const job = await QueryJobData({ socket, jobid: resolvedJobId }); - cb?.({ jobid: resolvedJobId, job }); - } catch (e) { - log("error", `RR query job data error: ${e.message}`, { jobid }); - cb?.({ jobid, error: e.message }); - } - }); - - /** - * NEW: Selected Customer — cache the chosen DMS customer (or upsert from job if not provided) - * payload: { jobid, selectedCustomerId?, bodyshopId? } - */ - socket.on("rr-selected-customer", async ({ jobid, selectedCustomerId, bodyshopId } = {}, cb) => { - try { - const resolvedJobId = resolveJobId(jobid, { jobid }, null); - const result = await SelectedCustomer({ - socket, - jobid: resolvedJobId, - bodyshopId, - selectedCustomerId, - redisHelpers - }); - cb?.({ jobid: resolvedJobId, selectedCustomerId: result.selectedCustomerId }); - } catch (e) { - log("error", `RR selected customer error: ${e.message}`, { jobid }); - cb?.({ jobid, error: e.message }); - } - }); - - // Calculate allocations (unchanged) - socket.on("rr-calculate-allocations", async (jobid, callback) => { - try { - const resolvedJobId = resolveJobId(jobid, { jobid }, null); - const allocations = await CdkCalculateAllocations(socket, resolvedJobId); - callback?.({ jobid: resolvedJobId, allocations }); - } catch (error) { - log("error", `Error during RR calculate allocations: ${error.message}`, { jobid, stack: error.stack }); - logger.log("rr-calc-allocations-error", "error", null, null, { - jobid, - message: error.message, - stack: error.stack - }); - callback?.({ jobid, error: error.message }); - } - }); - } - - module.exports = { registerRREvents }; // Call Handlers registerRoomAndBroadcastEvents(socket); registerUpdateEvents(socket); @@ -438,7 +370,14 @@ const redisSocketEvents = ({ registerSyncEvents(socket); registerTaskEvents(socket); registerFortellisEvents(socket); - registerRREvents(socket); + + // Reynolds & Reynolds socket handlers + registerRREvents({ + socket, + redisHelpers, + ioHelpers, + logger + }); }; // Associate Middleware and Handlers diff --git a/server/web-sockets/rr-register-socket-events.js b/server/web-sockets/rr-register-socket-events.js new file mode 100644 index 000000000..0edd34049 --- /dev/null +++ b/server/web-sockets/rr-register-socket-events.js @@ -0,0 +1,305 @@ +// server/rr/rr-register-socket-events.js + +const RRLogger = require("../rr/rr-logger"); +const { rrCombinedSearch, rrGetAdvisors, rrGetParts } = require("../rr/rr-lookup"); +const { QueryJobData } = require("../rr/rr-job-helpers"); +const { exportJobToRR } = require("../rr/rr-job-export"); +const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default; +const { createRRCustomer } = require("../rr/rr-customers"); + +const { GraphQLClient } = require("graphql-request"); +const queries = require("../graphql-client/queries"); + +// ---------------- utils ---------------- + +function resolveJobId(explicit, payload, job) { + return explicit || payload?.jobId || payload?.jobid || job?.id || job?.jobId || job?.jobid || null; +} + +async function getSessionOrSocket(redisHelpers, socket) { + let sess = null; + try { + sess = await redisHelpers.getSessionData(socket.id); + } catch { + /* ignore */ + } + const bodyshopId = sess?.bodyshopId ?? socket.bodyshopId; + const email = sess?.email ?? socket.user?.email; + if (!bodyshopId) throw new Error("No bodyshopId (session/socket)"); + return { bodyshopId, email, sess }; +} + +async function getBodyshopForSocket({ bodyshopId, socket }) { + const endpoint = process.env.GRAPHQL_ENDPOINT; + if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured"); + + const token = (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token); + + const client = new GraphQLClient(endpoint, {}); + const res = await client + .setHeaders({ Authorization: `Bearer ${token}` }) + .request(queries.GET_BODYSHOP_BY_ID, { id: bodyshopId }); + + const bodyshop = res?.bodyshops_by_pk; + if (!bodyshop) throw new Error(`Bodyshop not found: ${bodyshopId}`); + return bodyshop; +} + +// ---------------- register handlers ---------------- + +function registerRREvents({ socket, redisHelpers }) { + // RRLogger returns a log(level, message, ctx) function + const log = RRLogger(socket); + + // Lookups (mirrors Fortellis shape/flow) + socket.on("rr-lookup-combined", async ({ jobid, params } = {}, cb) => { + try { + const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); + const bodyshop = await getBodyshopForSocket({ bodyshopId, socket }); + + const res = await rrCombinedSearch(bodyshop, params || {}); + const data = res?.data ?? res; + + cb?.({ jobid: resolveJobId(jobid, { jobid }, null), data }); + + // Push to FE to open the table; keep payload as the raw array (FE maps columns itself) + socket.emit("rr-select-customer", Array.isArray(data) ? data : data?.customers || []); + } catch (e) { + log("error", `RR combined lookup error: ${e.message}`, { jobid }); + cb?.({ jobid, error: e.message }); + } + }); + + // Advisors + socket.on("rr-get-advisors", async (args = {}, ack) => { + try { + const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); + const bodyshop = await getBodyshopForSocket({ bodyshopId, socket }); + const res = await rrGetAdvisors(bodyshop, args); + ack?.({ ok: true, result: res }); + socket.emit("rr-get-advisors:result", res); + } catch (err) { + log("error", err?.message || "get advisors failed", { err }); + ack?.({ ok: false, error: err?.message || "get advisors failed" }); + } + }); + + // Parts + socket.on("rr-get-parts", async (args = {}, ack) => { + try { + const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); + const bodyshop = await getBodyshopForSocket({ bodyshopId, socket }); + const res = await rrGetParts(bodyshop, args); + ack?.({ ok: true, result: res }); + socket.emit("rr-get-parts:result", res); + } catch (err) { + log("error", err?.message || "get parts failed", { err }); + ack?.({ ok: false, error: err?.message || "get parts failed" }); + } + }); + + // Persist customer selection (or flag create-new) + socket.on("rr-selected-customer", async (selected, ack) => { + try { + await getSessionOrSocket(redisHelpers, socket); + const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {}; + + // Signal create-new intent + if (!selected || selected?.create === true || selected?.__new === true) { + await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrCreateCustomer: true }); + log("info", "rr-selected-customer:new-customer-intent"); + socket.emit("rr-customer-create-required"); + return ack?.({ ok: true, action: "create" }); + } + + await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrSelectedCustomer: selected }); + log("info", "rr-selected-customer", { selected }); + ack?.({ ok: true }); + } catch (err) { + log("error", err?.message || "select customer failed", { err }); + ack?.({ ok: false, error: err?.message || "select customer failed" }); + } + }); + + // Optional explicit create-customer from UI form + socket.on("rr-create-customer", async ({ jobId, fields } = {}, ack) => { + try { + const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); + const bodyshop = await getBodyshopForSocket({ bodyshopId, socket }); + + if (!jobId) throw new Error("jobId required"); + const job = await QueryJobData({ redisHelpers }, jobId); + + const { custNo } = await createRRCustomer({ bodyshop, job, overrides: fields || {}, socket }); + + const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {}; + await redisHelpers.setSessionTransactionData(socket.id, { + ...tx, + rrSelectedCustomer: { custNo }, + rrCreateCustomer: false + }); + + log("info", "rr-create-customer:success", { custNo }); + socket.emit("rr-customer-created", { custNo }); + ack?.({ ok: true, custNo }); + } catch (err) { + log("error", err?.message || "create customer failed", { err }); + ack?.({ ok: false, error: err?.message || "create customer failed" }); + } + }); + + // Vehicle selection helpers + socket.on("rr-selected-vehicle", async (selected, ack) => { + try { + await getSessionOrSocket(redisHelpers, socket); + if (!selected?.vin) throw new Error("selected vehicle must include vin"); + const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {}; + await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrSelectedVehicle: selected }); + log("info", "rr-selected-vehicle", { vin: selected.vin }); + ack?.({ ok: true }); + } catch (err) { + log("error", err?.message || "select vehicle failed", { err }); + ack?.({ ok: false, error: err?.message || "select vehicle failed" }); + } + }); + + socket.on("rr-create-vehicle", async (vehicle, ack) => { + try { + await getSessionOrSocket(redisHelpers, socket); + if (!vehicle?.vin) throw new Error("vehicle.vin required"); + const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {}; + await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrSelectedVehicle: vehicle }); + log("info", "rr-create-vehicle", { vin: vehicle.vin }); + ack?.({ ok: true }); + } catch (err) { + log("error", err?.message || "create vehicle failed", { err }); + ack?.({ ok: false, error: err?.message || "create vehicle failed" }); + } + }); + + // Export flow + // Export flow + socket.on("rr-export-job", async (payload = {}) => { + try { + // Extract job / ids + let job = payload.job || payload.txEnvelope?.job; + const jobId = payload.jobId || payload.jobid || payload.txEnvelope?.jobId || job?.id; + + if (!job) { + if (!jobId) throw new Error("RR export: job or jobId required"); + // Fetch full job when only jobId is provided + job = await QueryJobData({ redisHelpers }, jobId); + } + + // Resolve bodyshop id + let bodyshopId = payload.bodyshopId || payload.bodyshopid || payload.bodyshopUUID || job?.bodyshop?.id; + + if (!bodyshopId) { + const { bodyshopId: sid } = await getSessionOrSocket(redisHelpers, socket); + bodyshopId = sid; + } + if (!bodyshopId) throw new Error("RR export: bodyshopId required"); + + // Load authoritative bodyshop row (so rr-config can read routing) + let bodyshop = job?.bodyshop || (await getBodyshopForSocket({ bodyshopId, socket })) || { id: bodyshopId }; + + // Optional FE routing override (safe: routing only) + const feRouting = payload.rrRouting; + if (feRouting) { + const cfg = bodyshop.rr_configuration || {}; + bodyshop = { + ...bodyshop, + rr_dealerid: feRouting.dealerNumber ?? bodyshop.rr_dealerid, + rr_configuration: { + ...cfg, + storeNumber: feRouting.storeNumber ?? cfg.storeNumber, + branchNumber: feRouting.areaNumber ?? cfg.branchNumber, + areaNumber: feRouting.areaNumber ?? cfg.areaNumber + } + }; + } + + // Selected customer resolution (tx → payload → create) + const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {}; + let selectedCustomer = null; + + // from payload + if (payload.selectedCustomer) { + if (typeof payload.selectedCustomer === "object" && payload.selectedCustomer.custNo) { + selectedCustomer = { custNo: payload.selectedCustomer.custNo }; + } else if (typeof payload.selectedCustomer === "string") { + selectedCustomer = { custNo: payload.selectedCustomer }; + } + } + + // from tx if still not set + if (!selectedCustomer && tx.rrSelectedCustomer) { + if (typeof tx.rrSelectedCustomer === "object" && tx.rrSelectedCustomer.custNo) { + selectedCustomer = { custNo: tx.rrSelectedCustomer.custNo }; + } else { + selectedCustomer = { custNo: tx.rrSelectedCustomer }; + } + } + + // create on demand (flagged or missing) + if (!selectedCustomer || tx.rrCreateCustomer === true) { + const created = await createRRCustomer({ bodyshop, job, socket }); + selectedCustomer = { custNo: created.custNo }; + await redisHelpers.setSessionTransactionData(socket.id, { + ...tx, + rrSelectedCustomer: created.custNo, + rrCreateCustomer: false + }); + log("info", "rr-export-job:customer-created", { jobId, custNo: created.custNo }); + } + + const advisorNo = payload.advisorNo || payload.advNo || tx.rrAdvisorNo; + const options = payload.options || payload.txEnvelope?.options || {}; + + const result = await exportJobToRR({ + bodyshop, + job, + selectedCustomer, + advisorNo, + existing: payload.existing, + logger: log, + ...options + }); + + if (result?.success) { + socket.emit("export-success", { vendor: "rr", jobId, roStatus: result.roStatus }); + } else { + socket.emit("export-failed", { + vendor: "rr", + jobId, + roStatus: result?.roStatus, + error: result?.error || "RR export failed" + }); + } + + socket.emit("rr-export-job:result", { jobId, bodyshopId, result }); + } catch (error) { + const jobId = payload.jobId || payload.jobid || payload.txEnvelope?.jobId || payload?.job?.id; + log("error", `Error during RR export: ${error.message}`, { jobId, stack: error.stack }); + try { + socket.emit("export-failed", { vendor: "rr", jobId, error: error.message }); + } catch { + // ignore + } + } + }); + + // Allocations (RR reuses CDK calculator) + socket.on("rr-calculate-allocations", async (jobid, cb) => { + try { + const allocations = await CdkCalculateAllocations(socket, jobid); + cb?.(allocations); + socket.emit("rr-calculate-allocations:result", allocations); + } catch (e) { + log("error", `RR allocations error: ${e.message}`, { jobid }); + cb?.({ ok: false, error: e.message }); + } + }); +} + +module.exports = registerRREvents;