/** * 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+<"); // 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 = ` ${CreationDateTime} ${BODId} ${Sender?.Component ? `${Sender.Component}` : ""} ${Sender?.Task ? `${Sender.Task}` : ""} ${Sender?.ReferenceId ? `${Sender.ReferenceId}` : ""} RR ${Destination?.DealerNumber ? `${Destination.DealerNumber}` : ""} ${Destination?.StoreNumber ? `${Destination.StoreNumber}` : ""} ${Destination?.AreaNumber ? `${Destination.AreaNumber}` : ""} `.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 ` ${creds.username} ${creds.password} ${payloadWithAppArea} `; } // ---------- 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("")) { 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 };