258 lines
9.0 KiB
JavaScript
258 lines
9.0 KiB
JavaScript
/**
|
|
* @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");
|
|
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");
|
|
|
|
/* ------------------------------------------------------------------------------------------------
|
|
* Configuration
|
|
* ----------------------------------------------------------------------------------------------*/
|
|
|
|
/**
|
|
* 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 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 {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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 buildSoapEnvelopeWithHeader(renderedBodyXml, headerVars) {
|
|
const headerXml = await renderXmlTemplate("_EnvelopeHeader", headerVars);
|
|
|
|
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();
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------------------------------
|
|
* Core SOAP caller
|
|
* ----------------------------------------------------------------------------------------------*/
|
|
|
|
/**
|
|
* 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 };
|
|
}
|
|
|
|
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.");
|
|
}
|
|
|
|
/**
|
|
* Constructs and sends a SOAP call to the Reynolds & Reynolds endpoint.
|
|
*
|
|
* 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({
|
|
action,
|
|
body,
|
|
socket,
|
|
// redisHelpers,
|
|
jobid,
|
|
dealerConfig,
|
|
retries = 1
|
|
}) {
|
|
const correlationId = uuidv4();
|
|
|
|
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 || {});
|
|
}
|
|
|
|
// 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
|
|
};
|
|
|
|
// Build full SOAP envelope with proper header
|
|
const soapEnvelope = await buildSoapEnvelopeWithHeader(renderedBody, headerVars);
|
|
|
|
RRLogger(socket, "info", `RR → ${soapAction || "SOAP"} request`, {
|
|
jobid,
|
|
url,
|
|
correlationId
|
|
});
|
|
|
|
const headers = {
|
|
...RR_SOAP_HEADERS,
|
|
SOAPAction: soapAction,
|
|
"Content-Type": "text/xml; charset=utf-8",
|
|
"X-Request-Id": correlationId
|
|
};
|
|
|
|
let attempt = 0;
|
|
while (attempt <= retries) {
|
|
attempt += 1;
|
|
try {
|
|
const response = await axios.post(url, soapEnvelope, {
|
|
headers,
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------------------------------
|
|
* Exports
|
|
* ----------------------------------------------------------------------------------------------*/
|
|
|
|
const RRActions = RR_ACTIONS;
|
|
|
|
module.exports = {
|
|
MakeRRCall,
|
|
getDealerConfig,
|
|
renderXmlTemplate,
|
|
resolveRRConfig,
|
|
RRActions
|
|
};
|