From a73617fd3c3837cb80b0df41aede95c9cb27ba09 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 29 Oct 2025 12:53:53 -0400 Subject: [PATCH] feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint --- server/rr/rr-job-helpers.js | 54 +++++++ server/rr/rr-selected-customer.js | 100 ++++++++++++ server/rr/rrRoutes.js | 193 ++++++++++++------------ server/web-sockets/redisSocketEvents.js | 142 +++++++---------- 4 files changed, 311 insertions(+), 178 deletions(-) create mode 100644 server/rr/rr-job-helpers.js create mode 100644 server/rr/rr-selected-customer.js diff --git a/server/rr/rr-job-helpers.js b/server/rr/rr-job-helpers.js new file mode 100644 index 000000000..5c8cc5e5f --- /dev/null +++ b/server/rr/rr-job-helpers.js @@ -0,0 +1,54 @@ +// 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). + */ +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"); + + 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 res = await client + .setHeaders({ Authorization: `Bearer ${token}` }) + .request(queries.QUERY_JOBS_FOR_CDK_EXPORT, { id: jobid }); + + return res?.jobs_by_pk || null; +} + +/** + * 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. + */ +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; + + const vehicle = first?.Vehicle || first?.vehicle || first?.Veh || null; + const customer = first?.Customer || first?.customer || first?.Cust || null; + + return { + vin, + vehicle, + customer + }; +} + +module.exports = { + QueryJobData, + QueryDMSVehicleById, + getTransactionType +}; diff --git a/server/rr/rr-selected-customer.js b/server/rr/rr-selected-customer.js new file mode 100644 index 000000000..d4b707e51 --- /dev/null +++ b/server/rr/rr-selected-customer.js @@ -0,0 +1,100 @@ +// 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 index 6339f3212..84dad2810 100644 --- a/server/rr/rrRoutes.js +++ b/server/rr/rrRoutes.js @@ -1,34 +1,34 @@ // server/rr/rrRoutes.js -"use strict"; - const express = require("express"); const router = express.Router(); const RRLogger = require("./rr-logger"); const { RrApiError } = require("./rr-error"); - -const customerApi = require("./rr-customer"); -const roApi = require("./rr-repair-orders"); +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 --- - -function ok(res, payload = {}) { - return res.json({ success: true, ...payload }); -} - -function fail(res, error, status = 400) { - const message = error?.message || String(error); - return res.status(status).json({ success: false, error: message, code: error?.code }); -} - +// --- helpers & middleware (kept local for this router) --- function socketOf(req) { - try { - return req.app?.get?.("socket") || null; - } catch { - return null; - } + // 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) { @@ -39,117 +39,122 @@ function requireBodyshopId(req) { const bodyshopId = fromBody || fromJob || fromHeader; if (!bodyshopId) { throw new RrApiError( - "Missing bodyshopId (in body.bodyshopId, body.job.shopid/bodyshopId, or x-bodyshop-id header)", + "Missing bodyshopId (expected in body.bodyshopId, body.job.shopid/bodyshopId, or x-bodyshop-id header)", "BAD_REQUEST" ); } return bodyshopId; } -// --- customers --- - -router.post("/rr/customer/insert", async (req, res) => { - const socket = socketOf(req); +// --- sanity/config checks --- +router.get("/rr/config", async (req, res) => { try { const bodyshopId = requireBodyshopId(req); - const { customer } = req.body || {}; - if (!customer) throw new RrApiError("Missing 'customer' in request body", "BAD_REQUEST"); - const result = await customerApi.insertCustomer({ bodyshopId, payload: customer }); - RRLogger(socket)("info", "RR customer insert", { bodyshopId }); - return ok(res, { data: result.data }); + const cfg = await getRRConfigForBodyshop(bodyshopId); + return ok(res, { data: cfg }); } catch (e) { - RRLogger(socket)("error", "RR /rr/customer/insert failed", { error: e.message }); - return fail(res, e); - } -}); - -router.post("/rr/customer/update", async (req, res) => { - const socket = socketOf(req); - try { - const bodyshopId = requireBodyshopId(req); - const { customer } = req.body || {}; - if (!customer) throw new RrApiError("Missing 'customer' in request body", "BAD_REQUEST"); - const result = await customerApi.updateCustomer({ bodyshopId, payload: customer }); - RRLogger(socket)("info", "RR customer update", { bodyshopId }); - return ok(res, { data: result.data }); - } catch (e) { - RRLogger(socket)("error", "RR /rr/customer/update failed", { error: e.message }); - return fail(res, e); - } -}); - -// --- repair orders --- - -router.post("/rr/repair-order/create", async (req, res) => { - const socket = socketOf(req); - try { - const bodyshopId = requireBodyshopId(req); - const { ro } = req.body || {}; - if (!ro) throw new RrApiError("Missing 'ro' in request body", "BAD_REQUEST"); - const result = await roApi.createRepairOrder({ bodyshopId, payload: ro }); - RRLogger(socket)("info", "RR create RO", { bodyshopId }); - return ok(res, { data: result.data }); - } catch (e) { - RRLogger(socket)("error", "RR /rr/repair-order/create failed", { error: e.message }); - return fail(res, e); - } -}); - -router.post("/rr/repair-order/update", async (req, res) => { - const socket = socketOf(req); - try { - const bodyshopId = requireBodyshopId(req); - const { ro } = req.body || {}; - if (!ro) throw new RrApiError("Missing 'ro' in request body", "BAD_REQUEST"); - const result = await roApi.updateRepairOrder({ bodyshopId, payload: ro }); - RRLogger(socket)("info", "RR update RO", { bodyshopId }); - return ok(res, { data: result.data }); - } catch (e) { - RRLogger(socket)("error", "RR /rr/repair-order/update failed", { error: e.message }); return fail(res, e); } }); // --- lookups --- - router.post("/rr/lookup/advisors", async (req, res) => { - const socket = socketOf(req); try { const bodyshopId = requireBodyshopId(req); - const result = await lookupApi.getAdvisors({ bodyshopId, ...req.body }); - return ok(res, { data: result.data }); + const data = await lookupApi.getAdvisors({ bodyshopId, ...(req.body || {}) }); + return ok(res, { data }); } catch (e) { - RRLogger(socket)("error", "RR /rr/lookup/advisors failed", { error: e.message }); return fail(res, e); } }); router.post("/rr/lookup/parts", async (req, res) => { - const socket = socketOf(req); try { const bodyshopId = requireBodyshopId(req); - const result = await lookupApi.getParts({ bodyshopId, ...req.body }); - return ok(res, { data: result.data }); + const data = await lookupApi.getParts({ bodyshopId, ...(req.body || {}) }); + return ok(res, { data }); } catch (e) { - RRLogger(socket)("error", "RR /rr/lookup/parts failed", { error: e.message }); return fail(res, e); } }); -router.post("/rr/lookup/combined-search", async (req, res) => { - const socket = socketOf(req); +router.post("/rr/combined-search", async (req, res) => { try { const bodyshopId = requireBodyshopId(req); - const result = await lookupApi.combinedSearch({ bodyshopId, ...req.body }); - return ok(res, { data: result.data }); + 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) { - RRLogger(socket)("error", "RR /rr/lookup/combined-search failed", { error: e.message }); 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); diff --git a/server/web-sockets/redisSocketEvents.js b/server/web-sockets/redisSocketEvents.js index 4038c5aa9..8ace10c05 100644 --- a/server/web-sockets/redisSocketEvents.js +++ b/server/web-sockets/redisSocketEvents.js @@ -3,8 +3,10 @@ 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 { exportJobToRR } = require("../rr/rr-job-export"); + const lookupApi = require("../rr/rr-lookup"); +const { SelectedCustomer } = require("../rr/rr-selected-customer"); +const { QueryJobData } = require("../rr/rr-job-helpers"); const redisSocketEvents = ({ io, @@ -341,109 +343,80 @@ const redisSocketEvents = ({ }; // Reynolds & Reynolds socket events (uses new client-backed ops) - const registerRREvents = (socket) => { - const log = (level, message, ctx) => RRLogger(socket)(level, message, ctx); + function registerRREvents(socket) { + const logger = require("../utils/logger"); + const log = RRLogger(socket); + const { + redisHelpers // { setSessionData, getSessionData, ... setSessionTransactionData, getSessionTransactionData } + } = require("../utils/ioHelpers").getHelpers?.() || { redisHelpers: {} }; - const resolveBodyshopId = (payload, job) => - payload?.bodyshopId || socket.bodyshopId || job?.shopid || job?.bodyshopId; + const resolveJobId = (maybeId, packet, fallback) => maybeId || packet?.jobid || fallback; - const resolveJobId = (explicitJobId, payload, job) => - explicitJobId || - payload?.jobid || - payload?.txEnvelope?.jobid || - job?.id || - payload?.txEnvelope?.job?.id || - null; - - // Orchestrated Export (Customer → Vehicle → Repair Order) - socket.on("rr-export-job", async (payload = {}) => { + // Lookups + socket.on("rr-get-advisors", async (params = {}, cb) => { try { - const job = payload.job || payload.txEnvelope?.job; - const options = payload.options || payload.txEnvelope?.options || {}; - const bodyshopId = resolveBodyshopId(payload, job); - const jobid = resolveJobId(payload.jobid, payload, job); - - if (!job) { - log("error", "RR export missing job payload", { jobid }); - return; - } - if (!bodyshopId) { - log("error", "RR export missing bodyshopId", { jobid }); - return; - } - - const result = await exportJobToRR({ bodyshopId, job, logger: log, ...options }); - - // Broadcast keyed by bodyshop + include jobid - const room = getBodyshopRoom(bodyshopId); - io.to(room).emit("rr-export-job:result", { jobid, bodyshopId, result }); - } catch (error) { - const jobid = resolveJobId(payload?.jobid, payload, payload?.job || payload?.txEnvelope?.job); - log("error", `Error during RR export: ${error.message}`, { jobid, stack: error.stack }); - logger.log("rr-job-export-error", "error", null, null, { jobid, message: error.message, stack: error.stack }); - } - }); - - // Combined search (customer/vehicle) - socket.on("rr-lookup-combined", async ({ jobid, params } = {}, cb) => { - try { - const bodyshopId = resolveBodyshopId({ bodyshopId: params?.bodyshopId }, null); - const resolvedJobId = resolveJobId(jobid, { jobid }, null); - if (!bodyshopId) throw new Error("Missing bodyshopId"); - - const res = await lookupApi.combinedSearch({ bodyshopId, ...(params || {}) }); - cb?.({ jobid: resolvedJobId, data: res?.data ?? res }); - } catch (e) { - log("error", `RR combined lookup error: ${e.message}`, { jobid }); - cb?.({ jobid, error: e.message }); - } - }); - - // Get Advisors - socket.on("rr-get-advisors", async ({ jobid, params } = {}, cb) => { - try { - const bodyshopId = resolveBodyshopId({ bodyshopId: params?.bodyshopId }, null); - const resolvedJobId = resolveJobId(jobid, { jobid }, null); - if (!bodyshopId) throw new Error("Missing bodyshopId"); - + const bodyshopId = params.bodyshopId || socket?.user?.bodyshopid; const res = await lookupApi.getAdvisors({ bodyshopId, ...(params || {}) }); - cb?.({ jobid: resolvedJobId, data: res?.data ?? res }); + cb?.({ data: res?.data ?? res }); } catch (e) { - log("error", `RR get advisors error: ${e.message}`, { jobid }); - cb?.({ jobid, error: e.message }); + log("error", `RR get advisors error: ${e.message}`); + cb?.({ error: e.message }); } }); - // Get Parts - socket.on("rr-get-parts", async ({ jobid, params } = {}, cb) => { + socket.on("rr-get-parts", async (params = {}, cb) => { try { - const bodyshopId = resolveBodyshopId({ bodyshopId: params?.bodyshopId }, null); - const resolvedJobId = resolveJobId(jobid, { jobid }, null); - if (!bodyshopId) throw new Error("Missing bodyshopId"); - + const bodyshopId = params.bodyshopId || socket?.user?.bodyshopid; const res = await lookupApi.getParts({ bodyshopId, ...(params || {}) }); - cb?.({ jobid: resolvedJobId, data: res?.data ?? res }); + cb?.({ data: res?.data ?? res }); } catch (e) { - log("error", `RR get parts error: ${e.message}`, { jobid }); + 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 }); } }); - // Optional: Selected customer — currently a no-op for RR - socket.on("rr-selected-customer", async ({ jobid, selectedCustomerId } = {}) => { - const resolvedJobId = resolveJobId(jobid, { jobid }, null); - log("info", "rr-selected-customer not implemented for RR (no-op)", { - jobid: resolvedJobId, - selectedCustomerId - }); + /** + * 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 (CDK utility unchanged) + // 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 }); + 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, { @@ -454,8 +427,9 @@ const redisSocketEvents = ({ callback?.({ jobid, error: error.message }); } }); - }; + } + module.exports = { registerRREvents }; // Call Handlers registerRoomAndBroadcastEvents(socket); registerUpdateEvents(socket);