Files
bodyshop/server/rr/rr-helpers.js

352 lines
12 KiB
JavaScript

/**
* STAR-only SOAP transport + template rendering for Reynolds & Reynolds (Rome/RCI).
* - Renders Mustache STAR business templates (rey_*Req rooted with STAR ns)
* - Builds STAR SOAP envelope (ProcessMessage/payload/content + ApplicationArea)
* - Posts to RCI endpoint with STAR SOAPAction (full URI)
* - Parses XML response (faults + STAR payload result)
*/
"use strict";
const fs = require("fs/promises");
const path = require("path");
const axios = require("axios");
const mustache = require("mustache");
const { XMLParser } = require("fast-xml-parser");
const RRLogger = require("./rr-logger");
const {
RR_ACTIONS,
RR_SOAP_HEADERS,
RR_SOAP_ACTION,
RR_NS,
getBaseRRConfig,
normalizeRRDealerFields
} = require("./rr-constants");
const { RrApiError } = require("./rr-error");
const xmlFormatter = require("xml-formatter");
/**
* Collapse Mustache-induced whitespace and pretty print.
* - strips inner XML decl
* - removes lines that are only whitespace
* - collapses inter-tag whitespace
* - formats with consistent indentation
*/
function prettyPrintXml(xml) {
let s = xml;
// strip any inner XML declaration
s = s.replace(/^\s*<\?xml[^>]*\?>\s*/i, "");
// remove lines that are only whitespace
s = s.replace(/^[\t ]*(?:\r?\n)/gm, "");
// collapse whitespace strictly between tags (not inside text nodes)
s = s.replace(/>\s+</g, "><");
// final pretty print
return xmlFormatter(s, {
indentation: " ",
collapseContent: true, // keep short elements on one line
lineSeparator: "\n",
strictMode: false
});
}
// ---------- Public action map (compat with rr-test.js) ----------
const RRActions = Object.fromEntries(Object.entries(RR_ACTIONS).map(([k]) => [k, { action: k }]));
// ---------- Template cache ----------
const templateCache = new Map();
async function loadTemplate(templateName) {
if (templateCache.has(templateName)) return templateCache.get(templateName);
const filePath = path.join(__dirname, "xml-templates", `${templateName}.xml`);
const tpl = await fs.readFile(filePath, "utf8");
templateCache.set(templateName, tpl);
return tpl;
}
async function renderXmlTemplate(templateName, data) {
const tpl = await loadTemplate(templateName);
// Render and strip any XML declaration to keep a single root element for the BOD
const rendered = mustache.render(tpl, { STAR_NS: RR_NS.STAR_BUSINESS, ...(data || {}) });
return rendered.replace(/^\s*<\?xml[^>]*\?>\s*/i, "");
}
/**
* Resolve RR config for STAR transport.
*
* Policy:
* - Base (transport) settings (baseUrl, username, password, ppsysId, wssePasswordType, timeout) come from env.
* - Dealer identifiers (dealer/store/branch) MUST be provided by the caller (DB-driven).
* - We DO NOT fall back to env for dealer/store/branch here. Only rr-test.js is allowed to do that.
*/
async function resolveRRConfig(_socket, bodyshopConfig) {
const baseEnv = getBaseRRConfig();
const { dealerNumber, storeNumber, branchNumber } = normalizeRRDealerFields(bodyshopConfig || {});
if (!dealerNumber || !storeNumber || !branchNumber) {
throw new Error(
"Missing dealer/store/branch in RR config. These must be loaded from the database (no env fallback here)."
);
}
return {
baseUrl: bodyshopConfig?.baseUrl || baseEnv.baseUrl,
username: bodyshopConfig?.username || baseEnv.username,
password: bodyshopConfig?.password || baseEnv.password,
ppsysId: bodyshopConfig?.ppsysId || baseEnv.ppsysId,
wssePasswordType: bodyshopConfig?.wssePasswordType || baseEnv.wssePasswordType || "Text",
timeout: baseEnv.timeout,
// canonical identifiers (DB-driven only)
dealerNumber,
storeNumber,
branchNumber
};
}
// ---------- Response parsing ----------
function parseRRResponse(xml) {
const parser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true
});
const doc = parser.parse(xml);
// Envelope/Body
const body =
doc?.Envelope?.Body ||
doc?.["soapenv:Envelope"]?.["soapenv:Body"] ||
doc?.["SOAP-ENV:Envelope"]?.["SOAP-ENV:Body"] ||
doc?.["S:Envelope"]?.["S:Body"] ||
doc?.Body ||
doc;
// SOAP Fault?
const fault = body?.Fault || body?.["soap:Fault"];
if (fault) {
return {
success: false,
code: fault.faultcode || "SOAP_FAULT",
message: fault.faultstring || "Unknown SOAP Fault",
raw: xml
};
}
// STAR transport path: ProcessMessage/payload/content
const processMessage = body?.ProcessMessage || body?.["ns0:ProcessMessage"] || body?.["ProcessMessageResponse"];
if (processMessage?.payload?.content) {
const content = processMessage.payload.content;
if (content && typeof content === "object") {
const keys = Object.keys(content).filter((k) => k !== "@_id");
const respKey = keys.find((k) => /Resp$/.test(k)) || (keys[0] === "ApplicationArea" && keys[1]) || keys[0];
const respNode = respKey ? content[respKey] : content;
const resultCode = respNode?.ResultCode || respNode?.ResponseCode || respNode?.StatusCode || "OK";
const resultMessage = respNode?.ResultMessage || respNode?.ResponseMessage || respNode?.StatusMessage || null;
return {
success: ["OK", "Success"].includes(String(resultCode)),
code: resultCode,
message: resultMessage,
raw: xml,
parsed: respNode
};
}
}
// Fallback: first element under Body (just in case)
const keys = body && typeof body === "object" ? Object.keys(body) : [];
const respNode = keys.length ? body[keys[0]] : body;
const resultCode = respNode?.ResultCode || respNode?.ResponseCode || "OK";
const resultMessage = respNode?.ResultMessage || respNode?.ResponseMessage || null;
return {
success: resultCode === "OK" || resultCode === "Success",
code: resultCode,
message: resultMessage,
raw: xml,
parsed: respNode
};
}
// ---------- STAR envelope helpers ----------
function wrapWithApplicationArea(innerXml, { CreationDateTime, BODId, Sender, Destination }) {
// Strip any inner XML declaration (idempotent)
let xml = innerXml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "");
const appArea = `
<ApplicationArea>
<CreationDateTime>${CreationDateTime}</CreationDateTime>
<BODId>${BODId}</BODId>
<Sender>
${Sender?.Component ? `<Component>${Sender.Component}</Component>` : ""}
${Sender?.Task ? `<Task>${Sender.Task}</Task>` : ""}
${Sender?.ReferenceId ? `<ReferenceId>${Sender.ReferenceId}</ReferenceId>` : ""}
</Sender>
<Destination>
<DestinationNameCode>RR</DestinationNameCode>
${Destination?.DealerNumber ? `<DealerNumber>${Destination.DealerNumber}</DealerNumber>` : ""}
${Destination?.StoreNumber ? `<StoreNumber>${Destination.StoreNumber}</StoreNumber>` : ""}
${Destination?.AreaNumber ? `<AreaNumber>${Destination.AreaNumber}</AreaNumber>` : ""}
</Destination>
</ApplicationArea>`.trim();
// Inject right after the opening tag of the root element
xml = xml.replace(/^(\s*<[^!?][^>]*>)/, `$1\n${appArea}\n`);
return xml;
}
const TASK_BY_ACTION = {
CreateRepairOrder: { Task: "BSMRO", ReferenceId: "Insert" },
UpdateRepairOrder: { Task: "BSMRO", ReferenceId: "Update" },
InsertCustomer: { Task: "CU", ReferenceId: "Insert" },
UpdateCustomer: { Task: "CU", ReferenceId: "Update" },
InsertServiceVehicle: { Task: "SV", ReferenceId: "Insert" }
};
async function buildStarEnvelope(innerBusinessXml, creds, appArea = {}, action) {
const now = new Date().toISOString();
// Derive sensible defaults for Sender from action, unless caller provided explicit values
const senderDefaults =
appArea?.Sender ||
(action && TASK_BY_ACTION[action] ? { Component: "Rome", ...TASK_BY_ACTION[action] } : { Component: "Rome" });
const payloadWithAppArea = wrapWithApplicationArea(innerBusinessXml, {
CreationDateTime: appArea.CreationDateTime || now,
BODId: appArea.BODId || `BOD-${Date.now()}`,
Sender: senderDefaults,
Destination: appArea.Destination || {
DealerNumber: creds.dealerNumber,
StoreNumber: String(creds.storeNumber ?? "").padStart(2, "0"),
AreaNumber: String(creds.branchNumber || "01").padStart(2, "0")
}
});
return `<?xml version="1.0" encoding="utf-8"?>
<soapenv:Envelope xmlns:soapenc="${RR_NS.SOAP_ENC}" xmlns:soapenv="${RR_NS.SOAP_ENV}" xmlns:xsd="${RR_NS.XSD}" xmlns:xsi="${RR_NS.XSI}">
<soapenv:Header>
<wsse:Security soapenv:mustUnderstand="1" xmlns:wsse="${RR_NS.WSSE}">
<wsse:UsernameToken>
<wsse:Username>${creds.username}</wsse:Username>
<wsse:Password>${creds.password}</wsse:Password>
</wsse:UsernameToken>
</wsse:Security>
</soapenv:Header>
<soapenv:Body>
<ProcessMessage xmlns="${RR_NS.STAR_TRANSPORT}">
<payload xmlns:soap="${RR_NS.SOAP_ENV}" xmlns:xsi="${RR_NS.XSI}" xmlns:xsd="${RR_NS.XSD}" xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/03/addressing" xmlns:wsse="${RR_NS.WSSE}" xmlns:wsu="${RR_NS.WSU}" xmlns="${RR_NS.STAR_TRANSPORT}">
<content id="content0">
${payloadWithAppArea}
</content>
</payload>
</ProcessMessage>
</soapenv:Body>
</soapenv:Envelope>`;
}
// ---------- Main transport (STAR only) ----------
async function MakeRRCall({
action,
body,
appArea, // <-- allow explicit ApplicationArea overrides at the top level
socket,
dealerConfig, // required in runtime code; rr-test.js can still pass env-inflated cfg
retries = 1,
jobid
}) {
if (!action || !RR_ACTIONS[action]) {
throw new Error(`Invalid RR action: ${action}`);
}
// Prefer explicit dealerConfig from caller; otherwise enforce DB-provided config via resolveRRConfig
const cfg = dealerConfig || (await resolveRRConfig(socket, undefined));
const baseUrl = cfg.baseUrl;
if (!baseUrl) throw new Error("Missing RR base URL");
// Render STAR business body
const templateName = body?.template || action;
const renderedBusiness = await renderXmlTemplate(templateName, body?.data || {});
// Build STAR envelope (use explicit appArea if provided; else accept body.appArea; else derive from action)
const selectedAppArea = appArea || body?.appArea || {};
const envelope = await buildStarEnvelope(renderedBusiness, cfg, selectedAppArea, action);
const formattedEnvelope = prettyPrintXml(envelope);
// Guardrails
if (!formattedEnvelope.includes("<ProcessMessage") || !formattedEnvelope.includes("<ApplicationArea>")) {
throw new Error("STAR envelope malformed: missing ProcessMessage/ApplicationArea");
}
const headers = { ...RR_SOAP_HEADERS, SOAPAction: RR_SOAP_ACTION };
RRLogger(socket, "debug", `Sending RR SOAP request`, {
action,
soapAction: RR_SOAP_ACTION,
endpoint: baseUrl,
jobid,
mode: "STAR"
});
try {
const { data: responseXml } = await axios.post(baseUrl, formattedEnvelope, {
headers,
timeout: cfg.timeout
// Some RCI tenants require Basic in addition to WSSE
// auth: { username: cfg.username, password: cfg.password }
});
const parsed = parseRRResponse(responseXml);
if (!parsed.success) {
RRLogger(socket, "error", `RR ${action} failed`, {
code: parsed.code,
message: parsed.message
});
throw new RrApiError(parsed.message || `RR ${action} failed`, parsed.code || "RR_ERROR");
}
RRLogger(socket, "info", `RR ${action} success`, {
result: parsed.code,
message: parsed.message
});
return responseXml;
} catch (err) {
if (retries > 0) {
RRLogger(socket, "warn", `Retrying RR ${action} (${retries - 1} left)`, {
error: err.message
});
return MakeRRCall({
action,
body,
appArea: selectedAppArea,
socket,
dealerConfig: cfg,
retries: retries - 1,
jobid
});
}
RRLogger(socket, "error", `RR ${action} failed permanently`, { error: err.message });
throw err;
}
}
module.exports = {
MakeRRCall,
renderXmlTemplate,
resolveRRConfig,
parseRRResponse,
buildStarEnvelope,
RRActions
};