// File: server/web-sockets/rr-register-socket-events.js // RR events aligned to Fortellis flow with Fortellis-style logging via CreateRRLogEvent const CreateRRLogEvent = require("../rr/rr-logger-event"); const { rrCombinedSearch, rrGetAdvisors, rrGetParts, buildClientAndOpts } = 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 { ensureRRServiceVehicle } = require("../rr/rr-service-vehicles"); const { makeVehicleSearchPayloadFromJob, ownersFromVinBlocks, readAdvisorNo, getTransactionType, normalizeCustomerCandidates, defaultRRTTL, RRCacheEnums } = require("../rr/rr-utils"); const { GraphQLClient } = require("graphql-request"); const queries = require("../graphql-client/queries"); // 1 week TTL for advisors cache const ADVISORS_CACHE_TTL = 7 * 24 * 60 * 60; // seconds // ---------------- utils ---------------- function resolveJobId(explicit, payload, job) { return explicit || payload?.jobId || payload?.jobid || job?.id || job?.jobId || job?.jobid || null; } function resolveVin({ tx, job }) { // Prefer cached tx vin (if we made one), then common job shapes (v_vin for our schema) return tx?.jobData?.vin || job?.v_vin || job?.vehicle?.vin || job?.vin || job?.vehicleVin || null; } function sortVehicleOwnerFirst(list) { return list .map((v, i) => ({ v, i })) .sort((a, b) => { const ao = a.v?.isVehicleOwner ? 1 : 0; const bo = b.v?.isVehicleOwner ? 1 : 0; if (ao !== bo) return bo - ao; return a.i - b.i; }) .map(({ v }) => v); } async function getSessionOrSocket(redisHelpers, socket) { let sess = null; try { sess = await redisHelpers.getSessionData(socket.id); } catch { // } 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; } /** * Build advisors cache namespace + field (per bodyshop + routing + department) */ function buildAdvisorsCacheNS({ bodyshopId, routing, departmentType = "B" }) { const dealer = routing?.dealerNumber || "unknown"; const store = routing?.storeNumber || "none"; const area = routing?.areaNumber || "none"; const dept = (departmentType || "B").toUpperCase(); return { ns: `rr:advisors:${bodyshopId}:${dealer}:${store}:${area}`, field: `dept:${dept}` }; } /** * VIN + Full Name merge (export flow) */ async function rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers }) { const queriesList = []; // 1) Full Name (preferred) const firstName = job?.ownr_fn && String(job.ownr_fn).trim(); const lastName = job?.ownr_ln && String(job.ownr_ln).trim(); const company = job?.ownr_co_nm && String(job.ownr_co_nm).trim(); if (firstName || lastName) { queriesList.push({ q: { kind: "name", name: { fname: firstName || undefined, lname: lastName || undefined }, maxResults: 50 }, fromVin: false }); } else if (company) { queriesList.push({ q: { kind: "name", name: { name: company }, maxResults: 50 }, fromVin: false }); } // 2) VIN (owner association) const vehQ = makeVehicleSearchPayloadFromJob(job); if (vehQ && vehQ.kind === "vin") queriesList.push({ q: vehQ, fromVin: true }); if (!queriesList.length) return []; let ownersSet = null; const merged = []; for (const { q, fromVin } of queriesList) { try { CreateRRLogEvent(socket, "DEBUG", `{RR-SEARCH} Executing ${q.kind} query`, { q }); const res = await rrCombinedSearch(bodyshop, q); if (fromVin) { const blocks = Array.isArray(res?.data) ? res.data : []; ownersSet = ownersFromVinBlocks(blocks, job?.v_vin); try { await redisHelpers.setSessionTransactionData( socket.id, getTransactionType(job.id), RRCacheEnums.VINCandidates, blocks, defaultRRTTL ); } catch { // } } const norm = normalizeCustomerCandidates(res, { ownersSet }); merged.push(...norm); } catch (e) { CreateRRLogEvent(socket, "WARN", "Multi-search subquery failed", { kind: q.kind, error: e.message }); } } return sortVehicleOwnerFirst(merged); } // ---------------- register handlers ---------------- function registerRREvents({ socket, redisHelpers }) { // ---------- Lookup passthrough ---------- socket.on("rr-lookup-combined", async ({ jobid, params } = {}, cb) => { try { const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); const bodyshop = await getBodyshopForSocket({ bodyshopId, socket }); CreateRRLogEvent(socket, "DEBUG", "rr-lookup-combined: begin", { jobid, params }); const res = await rrCombinedSearch(bodyshop, params || {}); let ownersSet = null; if ((params?.kind || "").toLowerCase() === "vin") { const blocks = Array.isArray(res?.data) ? res.data : []; ownersSet = ownersFromVinBlocks(blocks); } const normalized = sortVehicleOwnerFirst(normalizeCustomerCandidates(res, { ownersSet })); const rid = resolveJobId(jobid, { jobid }, null); cb?.({ jobid: rid, data: normalized }); socket.emit("rr-select-customer", normalized); CreateRRLogEvent(socket, "DEBUG", "rr-lookup-combined: emitted rr-select-customer", { count: normalized.length }); } catch (e) { CreateRRLogEvent(socket, "ERROR", "RR combined lookup error", { error: e.message, jobid }); cb?.({ jobid, error: e.message }); } }); // ---------- Advisors (cached) ---------- socket.on("rr-get-advisors", async (args = {}, ack) => { const refresh = !!args?.refresh; const requestedDept = (args?.departmentType || "B").toUpperCase(); try { const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); const bodyshop = await getBodyshopForSocket({ bodyshopId, socket }); CreateRRLogEvent(socket, "DEBUG", "rr-get-advisors: begin", { args }); // Build routing to bind cache key to bodyshop + dealer/store/area const { client, opts } = await buildClientAndOpts(bodyshop); const routing = opts?.routing || client?.opts?.routing || {}; if (!routing?.dealerNumber) { throw new Error("rr-get-advisors: routing.dealerNumber required"); } const { ns, field } = buildAdvisorsCacheNS({ bodyshopId, routing, departmentType: requestedDept }); let result = null; let fromCache = false; // 1) Try cache (unless forced refresh) if (!refresh) { try { const cached = await redisHelpers.getProviderCache(ns, field); if (cached && Array.isArray(cached)) { result = cached; fromCache = true; CreateRRLogEvent(socket, "DEBUG", "rr-get-advisors: cache hit", { ns, field, count: cached.length, ttl: ADVISORS_CACHE_TTL }); } } catch (e) { CreateRRLogEvent(socket, "WARN", "rr-get-advisors: cache read failed", { ns, field, error: e?.message }); } } // 2) Fetch + cache when no cache or forced refresh if (!result) { const live = await rrGetAdvisors(bodyshop, { departmentType: requestedDept }); result = Array.isArray(live) ? live : []; try { await redisHelpers.setProviderCache(ns, field, result, ADVISORS_CACHE_TTL); CreateRRLogEvent(socket, "DEBUG", "rr-get-advisors: cache populated", { ns, field, count: result.length, ttl: ADVISORS_CACHE_TTL }); } catch (e) { CreateRRLogEvent(socket, "WARN", "rr-get-advisors: cache write failed", { ns, field, error: e?.message }); } } // 3) Respond ack?.({ ok: true, result, fromCache }); socket.emit("rr-get-advisors:result", { result, fromCache }); CreateRRLogEvent(socket, "DEBUG", "rr-get-advisors: success", { count: Array.isArray(result) ? result.length : undefined, fromCache }); } catch (err) { CreateRRLogEvent(socket, "ERROR", "rr-get-advisors: failed", { error: err?.message }); 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 }); CreateRRLogEvent(socket, "DEBUG", "rr-get-parts: begin", { args }); const res = await rrGetParts(bodyshop, args); ack?.({ ok: true, result: res }); socket.emit("rr-get-parts:result", res); CreateRRLogEvent(socket, "DEBUG", "rr-get-parts: success", { count: Array.isArray(res) ? res.length : undefined }); } catch (err) { CreateRRLogEvent(socket, "ERROR", "rr-get-parts: failed", { error: err?.message }); ack?.({ ok: false, error: err?.message || "get parts failed" }); } }); // ================= Fortellis-style two-step export ================= // 1) Stage export -> search (Full Name + VIN) -> emit rr-select-customer socket.on("rr-export-job", async ({ jobid, jobId, txEnvelope } = {}) => { const rid = resolveJobId(jobid || jobId, { jobId, jobid }, null); try { if (!rid) throw new Error("RR export: jobid required"); CreateRRLogEvent(socket, "DEBUG", `{1} Received RR export request`, { jobid: rid }); await redisHelpers.setSessionTransactionData( socket.id, getTransactionType(rid), RRCacheEnums.txEnvelope, txEnvelope || {}, defaultRRTTL ); CreateRRLogEvent(socket, "DEBUG", `{1.1} Cached txEnvelope`, { hasTxEnvelope: !!txEnvelope }); const job = await QueryJobData({ redisHelpers }, rid); await redisHelpers.setSessionTransactionData( socket.id, getTransactionType(rid), RRCacheEnums.JobData, job, defaultRRTTL ); CreateRRLogEvent(socket, "DEBUG", `{1.2} Cached JobData`, { vin: job?.v_vin, ro: job?.ro_number }); const adv = readAdvisorNo( { txEnvelope }, await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.AdvisorNo) ); if (adv) { await redisHelpers.setSessionTransactionData( socket.id, getTransactionType(rid), RRCacheEnums.AdvisorNo, String(adv), defaultRRTTL ); CreateRRLogEvent(socket, "DEBUG", `{1.3} Cached advisorNo`, { advisorNo: String(adv) }); } const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); const bodyshop = await getBodyshopForSocket({ bodyshopId, socket }); CreateRRLogEvent(socket, "DEBUG", `{2} Running multi-search (Full Name + VIN)`); const candidates = await rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers }); socket.emit("rr-select-customer", candidates); CreateRRLogEvent(socket, "DEBUG", `{2.1} Emitted rr-select-customer`, { count: candidates.length, anyOwner: candidates.some((c) => c.isVehicleOwner) }); } catch (error) { CreateRRLogEvent(socket, "ERROR", `Error during RR export (prepare)`, { error: error.message, stack: error.stack, jobid: rid }); try { socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message }); } catch { // } } }); // 2) Selection (or create) -> ensure vehicle -> export socket.on("rr-selected-customer", async ({ jobid, jobId, selectedCustomerId, custNo, create } = {}, ack) => { const rid = resolveJobId(jobid || jobId, { jobid, jobId }, null); try { if (!rid) throw new Error("jobid required"); CreateRRLogEvent(socket, "DEBUG", `{3} rr-selected-customer`, { jobid: rid, custNo, selectedCustomerId, create: !!create }); const ns = getTransactionType(rid); let selectedCustNo = (custNo && String(custNo)) || (selectedCustomerId && String(selectedCustomerId)) || (await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.SelectedCustomer)); const job = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.JobData); const txEnvelope = (await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.txEnvelope)) || {}; if (!job) throw new Error("Staged JobData not found (run rr-export-job first)."); const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); const bodyshop = await getBodyshopForSocket({ bodyshopId, socket }); // Create customer (if requested or none chosen) if (create === true || !selectedCustNo) { CreateRRLogEvent(socket, "DEBUG", `{3.1} Creating RR customer`); const created = await createRRCustomer({ bodyshop, job, socket }); selectedCustNo = String(created?.customerNo); if (!selectedCustNo) throw new Error("RR create customer returned no custNo"); CreateRRLogEvent(socket, "DEBUG", `{3.2} Created customer`, { custNo: selectedCustNo }); } // VIN owner pre-check try { const vehQ = makeVehicleSearchPayloadFromJob(job); if (vehQ && vehQ.kind === "vin" && job?.v_vin) { const resVin = await rrCombinedSearch(bodyshop, vehQ); const blocksVin = Array.isArray(resVin?.data) ? resVin.data : []; try { await redisHelpers.setSessionTransactionData( socket.id, ns, RRCacheEnums.VINCandidates, blocksVin, defaultRRTTL ); } catch { // } const ownersSet = ownersFromVinBlocks(blocksVin, job.v_vin); if (ownersSet?.size) { const sel = String(selectedCustNo); if (!ownersSet.has(sel)) { const [existingOwner] = Array.from(ownersSet).map(String); CreateRRLogEvent(socket, "DEBUG", `{3.2a} VIN exists; switching to VIN owner`, { vin: job.v_vin, selected: sel, existingOwner }); try { socket.emit("rr-vin-owner-mismatch", { ts: Date.now(), vin: job.v_vin, selectedCustomerNo: sel, existingOwner, message: "VIN already exists in RR under a different customer. Using the VIN's owner to continue the export." }); } catch { // } selectedCustNo = existingOwner; } } } } catch (e) { CreateRRLogEvent(socket, "WARN", `VIN owner pre-check failed; continuing with selected customer`, { error: e?.message }); } // Cache final/effective customer selection const effectiveCustNo = String(selectedCustNo); await redisHelpers.setSessionTransactionData( socket.id, ns, RRCacheEnums.SelectedCustomer, effectiveCustNo, defaultRRTTL ); CreateRRLogEvent(socket, "DEBUG", `{3.3} Cached selected customer`, { custNo: effectiveCustNo }); // Build client & routing const { client, opts } = await buildClientAndOpts(bodyshop); const routing = opts?.routing || client?.opts?.routing || null; if (!routing?.dealerNumber) throw new Error("ensureRRServiceVehicle: routing.dealerNumber required"); // Reconstruct a lightweight tx object (so resolveVin can use the same shape we logged at {1.2}) const tx = { jobData: { ...job, vin: job?.v_vin || job?.vin || job?.vehicleVin || undefined }, txEnvelope }; const vin = resolveVin({ tx, job }); if (!vin) { CreateRRLogEvent(socket, "ERROR", "{3.x} No VIN found for ensureRRServiceVehicle", { jobid: rid }); throw new Error("ensureRRServiceVehicle: vin required"); } CreateRRLogEvent(socket, "DEBUG", "{3.2} ensureRRServiceVehicle: starting", { jobid: rid, selectedCustomerNo: effectiveCustNo, vin, dealerNumber: routing.dealerNumber, storeNumber: routing.storeNumber, areaNumber: routing.areaNumber }); const ensured = await ensureRRServiceVehicle({ client, routing, bodyshop, // Normalize for any internal checks: selectedCustomerNo: effectiveCustNo, custNo: effectiveCustNo, customerNo: effectiveCustNo, vin, job, socket, redisHelpers }); CreateRRLogEvent(socket, "DEBUG", "{3.4} ensureRRServiceVehicle: done", ensured); // Advisor no const cachedAdvisor = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.AdvisorNo); const advisorNo = readAdvisorNo({ txEnvelope }, cachedAdvisor); if (!advisorNo) { CreateRRLogEvent(socket, "ERROR", `Advisor is required (advisorNo)`); socket.emit("export-failed", { vendor: "rr", jobId: rid, error: "Advisor is required (advisorNo)." }); return ack?.({ ok: false, error: "Advisor is required (advisorNo)." }); } await redisHelpers.setSessionTransactionData( socket.id, ns, RRCacheEnums.AdvisorNo, String(advisorNo), defaultRRTTL ); // Export CreateRRLogEvent(socket, "DEBUG", `{4} Performing RR export`); const result = await exportJobToRR({ bodyshop, job, selectedCustomer: { customerNo: effectiveCustNo, custNo: effectiveCustNo }, advisorNo: String(advisorNo), existing: txEnvelope?.existing, socket }); if (result?.success) { CreateRRLogEvent(socket, "DEBUG", `{5} Export success`, { roStatus: result.roStatus }); socket.emit("export-success", { vendor: "rr", jobId: rid, roStatus: result.roStatus }); ack?.({ ok: true, result }); } else { CreateRRLogEvent(socket, "ERROR", `Export failed`, { roStatus: result?.roStatus, error: result?.error }); socket.emit("export-failed", { vendor: "rr", jobId: rid, roStatus: result?.roStatus, error: result?.error || "RR export failed" }); ack?.({ ok: false, error: result?.error || "RR export failed", result }); } await redisHelpers.setSessionTransactionData( socket.id, ns, RRCacheEnums.ExportResult, result || {}, defaultRRTTL ); socket.emit("rr-export-job:result", { jobId: rid, bodyshopId, result }); } catch (error) { CreateRRLogEvent(socket, "ERROR", `Error during RR export (selected-customer)`, { error: error.message, stack: error.stack, jobid: rid }); try { socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message }); } catch { // } ack?.({ ok: false, error: error.message }); } }); // ---------- Allocations (parity) ---------- socket.on("rr-calculate-allocations", async (jobid, cb) => { try { CreateRRLogEvent(socket, "DEBUG", "rr-calculate-allocations: begin", { jobid }); const allocations = await CdkCalculateAllocations(socket, jobid); cb?.(allocations); socket.emit("rr-calculate-allocations:result", allocations); CreateRRLogEvent(socket, "DEBUG", "rr-calculate-allocations: success", { items: allocations?.length }); } catch (e) { CreateRRLogEvent(socket, "ERROR", "rr-calculate-allocations: failed", { error: e.message, jobid }); cb?.({ ok: false, error: e.message }); } }); } module.exports = registerRREvents;