feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint

This commit is contained in:
Dave
2025-10-07 16:45:06 -04:00
parent c149d457e7
commit 2ffc4b81f4
28 changed files with 2594 additions and 1642 deletions

View File

@@ -1,488 +1,257 @@
/**
* RR (Reynolds & Reynolds) helper module
* -----------------------------------------------------------------------------
* Responsibilities
* - Load env (.env.{NODE_ENV})
* - Provide token retrieval with simple Redis-backed caching
* - Normalized HTTP caller (MakeRRCall) with request-id + idempotency key
* - URL constructor w/ path + query params
* - Optional delayed/batch polling stub (DelayedCallback)
* - Central action registry (RRActions) with prod/uat base URLs (PLACEHOLDERS)
* - Common cache enums + TTL + transaction-type helper (parity with Fortellis)
*
* Whats missing / TODOs to make this “real” (per RR/Rome PDFs you provided):
* - Implement the actual RR auth/token flow inside getRRToken()
* - Replace all RRActions URLs with the final endpoints from the RR spec
* - Confirm final header names (e.g., X-Request-Id, Idempotency-Key)
* - If RR uses async “batch/status/result”, adapt DelayedCallback() to spec
* - Confirm success/error envelope and centralize in rr-error.js
* @file rr-helpers.js
* @description Core helper functions for Reynolds & Reynolds integration.
* Handles XML rendering, SOAP communication, and configuration merging.
*/
const fs = require("fs/promises");
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const mustache = require("mustache");
const axios = require("axios");
const { v4: uuidv4 } = require("uuid");
const { RR_SOAP_HEADERS, RR_ACTIONS, getBaseRRConfig } = require("./rr-constants");
const RRLogger = require("./rr-logger");
const { client } = require("../graphql-client/graphql-client");
const { GET_BODYSHOP_BY_ID } = require("../graphql-client/queries");
const uuid = require("uuid").v4;
const AxiosLib = require("axios").default;
const axios = AxiosLib.create();
const axiosCurlirize = require("axios-curlirize").default;
/* ------------------------------------------------------------------------------------------------
* Configuration
* ----------------------------------------------------------------------------------------------*/
const logger = require("../utils/logger");
const { RrApiError } = require("./rr-error");
// Emit curl equivalents for dev troubleshooting (safe to disable in prod)
axiosCurlirize(axios, (result /*, err */) => {
/**
* Loads the rr_configuration JSON for a given bodyshop directly from the database.
* Dealer-level settings only. Platform/secret defaults come from getBaseRRConfig().
* @param {string} bodyshopId
* @returns {Promise<object>} rr_configuration
*/
async function getDealerConfig(bodyshopId) {
try {
const { command } = result;
// Pipe to your centralized logger if preferred:
// logger.log("rr-axios-curl", "DEBUG", "api", null, { command });
if (process.env.NODE_ENV !== "production") {
console.log("*** rr axios (curl):", command);
}
} catch {
// Best-effort only
}
});
const isProduction = process.env.NODE_ENV === "production";
/**
* Transaction key namespace (mirrors Fortellis' getTransactionType)
* Used to partition per-job Redis session hashes.
*/
const getTransactionType = (jobid) => `rr:${jobid}`;
/**
* Default per-transaction TTL for RR data cached in Redis (seconds).
* Keep parity with the Fortellis helper to avoid drift.
*/
const defaultRRTTL = 60 * 60; // 1 hour
/**
* Namespaced keys stored under each transaction hash (parity with Fortellis)
* These are referenced across rr-job-export.js (and friends).
*/
const RRCacheEnums = {
txEnvelope: "txEnvelope",
DMSBatchTxn: "DMSBatchTxn",
SubscriptionMeta: "SubscriptionMeta", // kept for parity; not used yet for RR
DepartmentId: "DepartmentId", // kept for parity; not used yet for RR
JobData: "JobData",
DMSVid: "DMSVid",
DMSVeh: "DMSVeh",
DMSVehCustomer: "DMSVehCustomer",
DMSCustList: "DMSCustList",
DMSCust: "DMSCust",
selectedCustomerId: "selectedCustomerId",
DMSTransHeader: "DMSTransHeader",
transWips: "transWips",
DmsBatchTxnPost: "DmsBatchTxnPost",
DMSVehHistory: "DMSVehHistory"
};
/**
* Provider-level token cache.
* We reuse redisHelpers.setSessionData/getSessionData with a synthetic "socketId"
* so we dont need direct access to the Redis client here.
*/
const RR_PROVIDER_TOKEN_BUCKET = "rr:provider-token"; // becomes key: "socket:rr:provider-token"
const RR_PROVIDER_TOKEN_FIELD = "token";
/**
* Fetch an RR access token.
* TODO: Implement the *actual* RR auth flow per the spec (client credentials
* or whatever RCI requires). This stub uses an env or a fixed dev token.
*
* @param {Object} deps
* @param {Object} deps.redisHelpers - Your redisHelpers API
* @returns {Promise<string>} accessToken
*/
async function getRRToken({ redisHelpers }) {
try {
// Try the cache first
const cached = await redisHelpers.getSessionData(RR_PROVIDER_TOKEN_BUCKET, RR_PROVIDER_TOKEN_FIELD);
if (cached?.accessToken && cached?.expiresAt && Date.now() < cached.expiresAt - 5000) {
return cached.accessToken;
}
// TODO: Replace with real RR auth call. For now, fallback to env.
const accessToken = process.env.RR_FAKE_TOKEN || "rr-dev-token";
// Artificial ~55m expiry (adjust to actual token TTL)
const expiresAt = Date.now() + 55 * 60 * 1000;
await redisHelpers.setSessionData(
RR_PROVIDER_TOKEN_BUCKET,
RR_PROVIDER_TOKEN_FIELD,
{ accessToken, expiresAt },
60 * 60 // TTL safety net
);
return accessToken;
} catch (error) {
logger.log("rr-get-token-error", "ERROR", "api", "rr", {
message: error?.message,
stack: error?.stack
});
// Keep local dev moving even if cache errors
return process.env.RR_FAKE_TOKEN || "rr-dev-token";
const result = await client.request(GET_BODYSHOP_BY_ID, { id: bodyshopId });
const cfg = result?.bodyshops_by_pk?.rr_configuration || {};
return cfg;
} catch (err) {
console.error(`[RR] Failed to load rr_configuration for bodyshop ${bodyshopId}:`, err.message);
return {};
}
}
/**
* Construct a full URL including optional path segment and query params.
* Matches the function signature used elsewhere in the codebase.
*
* @param {Object} args
* @param {string} args.url - base URL (may or may not end with "/")
* @param {string} [args.pathParams] - string appended to URL (no leading slash)
* @param {Array<[string,string]>} [args.requestSearchParams] - pairs converted to query params
* @returns {string}
* Helper to retrieve combined configuration (env + dealer) for calls.
* NOTE: This does not hit Redis. DB only (dealer overrides) + env secrets.
* @param {object} socket - Either a real socket or an Express req carrying bodyshopId on .bodyshopId
* @returns {Promise<object>} configuration
*/
function constructFullUrl({ url, pathParams = "", requestSearchParams = [] }) {
// normalize: ensure exactly one trailing slash on base
url = url.replace(/\/+$/, "/");
const fullPath = pathParams ? `${url}${pathParams}` : url;
const query = new URLSearchParams(requestSearchParams).toString();
return query ? `${fullPath}?${query}` : fullPath;
async function resolveRRConfig(socket) {
const bodyshopId = socket?.bodyshopId || socket?.user?.bodyshopid;
const dealerCfg = bodyshopId ? await getDealerConfig(bodyshopId) : {};
return { ...getBaseRRConfig(), ...dealerCfg };
}
/* ------------------------------------------------------------------------------------------------
* Template rendering
* ----------------------------------------------------------------------------------------------*/
/**
* Loads and renders a Mustache XML template with provided data.
* @param {string} templateName - Name of XML file under server/rr/xml-templates/ (without .xml)
* @param {object} data - Template substitution object
* @returns {Promise<string>} Rendered XML string
*/
async function renderXmlTemplate(templateName, data) {
const templatePath = path.join(__dirname, "xml-templates", `${templateName}.xml`);
const xmlTemplate = await fs.readFile(templatePath, "utf8");
return mustache.render(xmlTemplate, data);
}
/**
* Optional delayed/batch polling flow (placeholder).
* If RR returns a "check later" envelope, use this to poll until "complete".
* Adjust the header names and result shapes once you have the real spec.
*
* @param {Object} args
* @param {Object} args.delayMeta - body returned from initial RR call with status link(s)
* @param {string} args.access_token - token to reuse for polling
* @param {string} args.reqId - correlation id
* @returns {Promise<any>}
* Build a SOAP envelope with a rendered header + body.
* Header comes from xml-templates/_EnvelopeHeader.xml.
* @param {string} renderedBodyXml
* @param {object} headerVars - values for header mustache
* @returns {Promise<string>}
*/
async function DelayedCallback({ delayMeta, access_token, reqId }) {
// Stub example — adapt to RR if they do a batch/status-result pattern
for (let attempt = 0; attempt < 5; attempt++) {
await sleep((delayMeta?.checkStatusAfterSeconds || 2) * 1000);
async function buildSoapEnvelopeWithHeader(renderedBodyXml, headerVars) {
const headerXml = await renderXmlTemplate("_EnvelopeHeader", headerVars);
const statusUrl = delayMeta?._links?.status?.href;
if (!statusUrl) {
return { error: "No status URL provided by RR batch envelope." };
}
return `
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:rr="http://reynoldsandrey.com/">
<soapenv:Header>
${headerXml}
</soapenv:Header>
<soapenv:Body>
${renderedBodyXml}
</soapenv:Body>
</soapenv:Envelope>
`.trim();
}
const statusResult = await axios.get(statusUrl, {
headers: {
Authorization: `Bearer ${access_token}`,
"X-Request-Id": reqId
}
});
/* ------------------------------------------------------------------------------------------------
* Core SOAP caller
* ----------------------------------------------------------------------------------------------*/
if (statusResult?.data?.status === "complete") {
const resultUrl = statusResult?.data?._links?.result?.href;
if (!resultUrl) return statusResult.data;
const batchResult = await axios.get(resultUrl, {
headers: {
Authorization: `Bearer ${access_token}`,
"X-Request-Id": reqId
}
});
return batchResult.data;
}
/**
* Compute the full URL and SOAPAction for a given action spec.
* Allows either:
* - action: a key into RR_ACTIONS (e.g. "GetAdvisors")
* - action: a raw URL/spec
*/
function resolveActionTarget(action, baseUrl) {
if (typeof action === "string" && RR_ACTIONS[action]) {
const spec = RR_ACTIONS[action];
const soapAction = spec.soapAction || spec.action || action;
const cleanedBase = (spec.baseUrl || baseUrl || "").replace(/\/+$/, "");
const url = spec.url || (soapAction ? `${cleanedBase}/${soapAction}` : cleanedBase);
return { url, soapAction };
}
return { error: "Batch result still not complete after max attempts." };
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
if (action && typeof action === "object" && (action.url || action.soapAction || action.action)) {
const soapAction = action.soapAction || action.action || "";
const cleanedBase = (action.baseUrl || baseUrl || "").replace(/\/+$/, "");
const url = action.url || (soapAction ? `${cleanedBase}/${soapAction}` : cleanedBase);
return { url, soapAction };
}
if (typeof action === "string") {
return { url: action, soapAction: "" };
}
throw new Error("Invalid RR action. Must be a known RR_ACTIONS key, an action spec, or a URL string.");
}
/**
* Core caller. Mirrors Fortellis' MakeFortellisCall shape so we can reuse flow.
* Constructs and sends a SOAP call to the Reynolds & Reynolds endpoint.
*
* @param {Object} args
* @param {string} args.apiName - logical name (used in logs/errors)
* @param {string} args.url - base endpoint
* @param {Object} [args.headers] - extra headers to send
* @param {Object} [args.body] - POST/PUT body
* @param {"get"|"post"|"put"|"delete"} [args.type="post"]
* @param {boolean} [args.debug=true]
* @param {string} [args.requestPathParams] - path segment to append to url
* @param {Array<[string,string]>} [args.requestSearchParams=[]] - tuples of [key, val] for query params
* @param {string|number} [args.jobid] - used for logger correlation (optional)
* @param {Object} args.redisHelpers - your redisHelpers api (for token cache)
* @param {Object} [args.socket] - pass-through so we can pull user/email if needed
* @returns {Promise<any>}
* Body can be one of:
* - string (already-rendered XML body)
* - { template: "TemplateName", data: {...} } to render server/rr/xml-templates/TemplateName.xml
*
* @param {object} params
* @param {string|object} params.action - RR action key (RR_ACTIONS) or a raw URL/spec
* @param {string|{template:string,data:object}} params.body - Rendered XML or template descriptor
* @param {object} params.socket - The socket or req object for context (used to resolve config & logging)
* @param {object} [params.redisHelpers]
* @param {string|number} [params.jobid]
* @param {object} [params.dealerConfig]
* @param {number} [params.retries=1]
* @returns {Promise<string>} Raw SOAP response text
*/
async function MakeRRCall({
apiName,
url,
headers = {},
body = {},
type = "post",
debug = true,
requestPathParams,
requestSearchParams = [],
action,
body,
socket,
// redisHelpers,
jobid,
redisHelpers,
socket
dealerConfig,
retries = 1
}) {
const fullUrl = constructFullUrl({ url, pathParams: requestPathParams, requestSearchParams });
const reqId = uuid();
const idempotencyKey = uuid();
const access_token = await getRRToken({ redisHelpers });
const correlationId = uuidv4();
if (debug) {
logger.log("rr-call", "DEBUG", socket?.user?.email, null, {
apiName,
type,
url: fullUrl,
jobid,
reqId,
body: safeLogJson(body)
});
const effectiveConfig = dealerConfig || (await resolveRRConfig(socket));
const { url, soapAction } = resolveActionTarget(action, effectiveConfig.baseUrl);
// Render body if given by template descriptor
let renderedBody = body;
if (body && typeof body === "object" && body.template) {
renderedBody = await renderXmlTemplate(body.template, body.data || {});
}
try {
const baseHeaders = {
Authorization: `Bearer ${access_token}`,
"X-Request-Id": reqId,
"Idempotency-Key": idempotencyKey,
...headers
};
// Build header vars (from env + rr_configuration)
const headerVars = {
PPSysId: effectiveConfig.ppsysid || process.env.RR_PPSYSID || process.env.RR_PP_SYS_ID || process.env.RR_PP_SYSID,
DealerNumber: effectiveConfig.dealer_number || effectiveConfig.dealer_id || process.env.RR_DEALER_NUMBER,
StoreNumber: effectiveConfig.store_number || process.env.RR_STORE_NUMBER,
BranchNumber: effectiveConfig.branch_number || process.env.RR_BRANCH_NUMBER,
Username: effectiveConfig.username || process.env.RR_API_USER || process.env.RR_USERNAME,
Password: effectiveConfig.password || process.env.RR_API_PASS || process.env.RR_PASSWORD,
CorrelationId: correlationId
};
let resp;
switch ((type || "post").toLowerCase()) {
case "get":
resp = await axios.get(fullUrl, { headers: baseHeaders });
break;
case "put":
resp = await axios.put(fullUrl, body, { headers: baseHeaders });
break;
case "delete":
// Some APIs require body with DELETE; axios supports { data } for that
resp = await axios.delete(fullUrl, { headers: baseHeaders, data: body });
break;
case "post":
default:
resp = await axios.post(fullUrl, body, { headers: baseHeaders });
break;
}
// Build full SOAP envelope with proper header
const soapEnvelope = await buildSoapEnvelopeWithHeader(renderedBody, headerVars);
if (debug) {
logger.log("rr-response", "DEBUG", socket?.user?.email, null, {
apiName,
reqId,
data: safeLogJson(resp?.data)
});
}
RRLogger(socket, "info", `RR → ${soapAction || "SOAP"} request`, {
jobid,
url,
correlationId
});
// If RR returns a "check later" envelope, route through DelayedCallback
if (resp?.data?.checkStatusAfterSeconds) {
const delayed = await DelayedCallback({
delayMeta: resp.data,
access_token,
reqId
});
return delayed;
}
const headers = {
...RR_SOAP_HEADERS,
SOAPAction: soapAction,
"Content-Type": "text/xml; charset=utf-8",
"X-Request-Id": correlationId
};
return resp?.data;
} catch (error) {
// Handle 429 backoff hint (simple single-retry stub)
if (error?.response?.status === 429) {
const retryAfter = Number(error.response.headers?.["retry-after"] || 1);
await sleep(retryAfter * 1000);
return MakeRRCall({
apiName,
url,
let attempt = 0;
while (attempt <= retries) {
attempt += 1;
try {
const response = await axios.post(url, soapEnvelope, {
headers,
body,
type,
debug,
requestPathParams,
requestSearchParams,
jobid,
redisHelpers,
socket
timeout: effectiveConfig.timeout || 30000,
responseType: "text",
validateStatus: () => true
});
const text = response.data;
if (response.status >= 400) {
RRLogger(socket, "error", `RR HTTP ${response.status} on ${soapAction || url}`, {
status: response.status,
jobid,
correlationId,
snippet: text?.slice?.(0, 512)
});
if (response.status >= 500 && attempt <= retries) {
RRLogger(socket, "warn", `RR transient HTTP error; retrying (${attempt}/${retries})`, {
correlationId
});
continue;
}
throw new Error(`RR HTTP ${response.status}: ${response.statusText}`);
}
RRLogger(socket, "debug", `RR ← ${soapAction || "SOAP"} response`, {
jobid,
correlationId,
bytes: Buffer.byteLength(text || "", "utf8")
});
return text;
} catch (err) {
const transient = /ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|socket hang up|network error/i.test(
err?.message || ""
);
if (transient && attempt <= retries) {
RRLogger(socket, "warn", `RR transient network error; retrying (${attempt}/${retries})`, {
error: err.message,
correlationId
});
continue;
}
RRLogger(socket, "error", `RR ${soapAction || "SOAP"} failed`, {
error: err.message,
jobid,
correlationId
});
throw err;
}
const errPayload = {
reqId,
url: fullUrl,
apiName,
errorData: error?.response?.data,
status: error?.response?.status,
statusText: error?.response?.statusText
};
// Log and throw a typed error (consistent with Fortellis helpers)
logger.log("rr-call-error", "ERROR", socket?.user?.email, null, {
...errPayload,
message: error?.message,
stack: error?.stack
});
throw new RrApiError(`RR API call failed for ${apiName}: ${error?.message}`, errPayload);
}
}
/**
* Central action registry.
* TODO: Replace ALL URLs with real RR endpoints from the Rome/RR specs.
* You can later split into domain-specific registries if it grows large.
*/
const RRActions = {
// Vehicles
GetVehicleId: {
apiName: "RR Get Vehicle Id",
url: isProduction
? "https://rr.example.com/service-vehicle-mgmt/v1/vehicle-ids/" // append VIN
: "https://rr-uat.example.com/service-vehicle-mgmt/v1/vehicle-ids/",
type: "get"
},
ReadVehicle: {
apiName: "RR Read Vehicle",
url: isProduction
? "https://rr.example.com/service-vehicle-mgmt/v1/" // append vehicleId
: "https://rr-uat.example.com/service-vehicle-mgmt/v1/",
type: "get"
},
InsertVehicle: {
apiName: "RR Insert Service Vehicle",
url: isProduction
? "https://rr.example.com/service-vehicle-mgmt/v1/"
: "https://rr-uat.example.com/service-vehicle-mgmt/v1/",
type: "post"
},
UpdateVehicle: {
apiName: "RR Update Service Vehicle",
url: isProduction
? "https://rr.example.com/service-vehicle-mgmt/v1/"
: "https://rr-uat.example.com/service-vehicle-mgmt/v1/",
type: "put"
},
/* ------------------------------------------------------------------------------------------------
* Exports
* ----------------------------------------------------------------------------------------------*/
// Customers
CreateCustomer: {
apiName: "RR Create Customer",
url: isProduction ? "https://rr.example.com/customer/v1/" : "https://rr-uat.example.com/customer/v1/",
type: "post"
},
UpdateCustomer: {
apiName: "RR Update Customer",
url: isProduction
? "https://rr.example.com/customer/v1/" // append /{id} if required by spec
: "https://rr-uat.example.com/customer/v1/",
type: "put"
},
ReadCustomer: {
apiName: "RR Read Customer",
url: isProduction
? "https://rr.example.com/customer/v1/" // append /{id}
: "https://rr-uat.example.com/customer/v1/",
type: "get"
},
SearchCustomer: {
apiName: "RR Query Customer By Name",
url: isProduction ? "https://rr.example.com/customer/v1/search" : "https://rr-uat.example.com/customer/v1/search",
type: "get"
},
// Combined search (customer + vehicle)
CombinedSearch: {
apiName: "RR Combined Search (Customer + Vehicle)",
url: isProduction
? "https://rr.example.com/search/v1/customer-vehicle"
: "https://rr-uat.example.com/search/v1/customer-vehicle",
type: "get"
},
// Advisors
GetAdvisors: {
apiName: "RR Get Advisors",
url: isProduction ? "https://rr.example.com/advisors/v1" : "https://rr-uat.example.com/advisors/v1",
type: "get"
},
// Parts
GetParts: {
apiName: "RR Get Parts",
url: isProduction ? "https://rr.example.com/parts/v1" : "https://rr-uat.example.com/parts/v1",
type: "get"
},
// GL / WIP (mirroring your existing flows; endpoints are placeholders)
StartWip: {
apiName: "RR Start WIP",
url: isProduction ? "https://rr.example.com/glpost/v1/startWIP" : "https://rr-uat.example.com/glpost/v1/startWIP",
type: "post"
},
TranBatchWip: {
apiName: "RR Trans Batch WIP",
url: isProduction
? "https://rr.example.com/glpost/v1/transBatchWIP"
: "https://rr-uat.example.com/glpost/v1/transBatchWIP",
type: "post"
},
PostBatchWip: {
apiName: "RR Post Batch WIP",
url: isProduction
? "https://rr.example.com/glpost/v1/postBatchWIP"
: "https://rr-uat.example.com/glpost/v1/postBatchWIP",
type: "post"
},
QueryErrorWip: {
apiName: "RR Query Error WIP",
url: isProduction ? "https://rr.example.com/glpost/v1/errWIP" : "https://rr-uat.example.com/glpost/v1/errWIP",
type: "get"
},
// Service history (header insert)
ServiceHistoryInsert: {
apiName: "RR Service Vehicle History Insert",
url: isProduction
? "https://rr.example.com/service-vehicle-history-mgmt/v1/"
: "https://rr-uat.example.com/service-vehicle-history-mgmt/v1/",
type: "post"
},
// Repair Orders
CreateRepairOrder: {
apiName: "RR Create Repair Order",
url: isProduction ? "https://rr.example.com/repair-orders/v1" : "https://rr-uat.example.com/repair-orders/v1",
type: "post"
},
UpdateRepairOrder: {
apiName: "RR Update Repair Order",
url: isProduction
? "https://rr.example.com/repair-orders/v1/" // append /{id} if required
: "https://rr-uat.example.com/repair-orders/v1/",
type: "put"
}
};
/**
* Safe JSON logger helper to avoid huge payloads/recursive structures in logs.
*/
function safeLogJson(data) {
try {
const text = JSON.stringify(data);
// cap to ~5k for logs
return text.length > 5000 ? `${text.slice(0, 5000)}… [truncated]` : text;
} catch {
return "[unserializable]";
}
}
const RRActions = RR_ACTIONS;
module.exports = {
// core helpers
MakeRRCall,
RRActions,
getRRToken,
constructFullUrl,
DelayedCallback,
// parity exports required by other RR modules
getTransactionType,
defaultRRTTL,
RRCacheEnums
getDealerConfig,
renderXmlTemplate,
resolveRRConfig,
RRActions
};