feature/Reynolds-and-Reynolds-DMS-API-Integration -Expand

This commit is contained in:
Dave
2025-10-01 17:11:34 -04:00
parent 42027f0858
commit 24f017bfd2
11 changed files with 744 additions and 333 deletions

View File

@@ -1,14 +1,28 @@
/**
* RR (Reynolds & Reynolds) helper module
* - Loads env (.env.{NODE_ENV})
* - Provides token retrieval + simple Redis-backed caching
* -----------------------------------------------------------------------------
* 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
* - Exports everything needed by rr-* feature files
* - 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
*/
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const uuid = require("uuid").v4;
const AxiosLib = require("axios").default;
const axios = AxiosLib.create();
@@ -17,29 +31,71 @@ const axiosCurlirize = require("axios-curlirize").default;
const logger = require("../utils/logger");
const { RrApiError } = require("./rr-error");
// Optional curl logging (handy while scaffolding)
// Emit curl equivalents for dev troubleshooting (safe to disable in prod)
axiosCurlirize(axios, (result /*, err */) => {
const { command } = result;
// Disable or pipe to your logger if you prefer:
// logger.log("rr-axios-curl", "DEBUG", "api", null, { command });
// Keeping a console for local scaffolding/bring-up:
console.log("*** rr axios (curl):", command);
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";
/**
* Simple provider-level token cache using existing session helpers.
* We re-use setSessionData/getSessionData with a synthetic "socketId"
* key (so we don't need pubClient here).
* Transaction key namespace (mirrors Fortellis' getTransactionType)
* Used to partition per-job Redis session hashes.
*/
const RR_PROVIDER_TOKEN_BUCKET = "rr:provider-token";
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. Replace with the real auth call when available.
* 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
* @param {Object} deps.redisHelpers - Your redisHelpers API
* @returns {Promise<string>} accessToken
*/
async function getRRToken({ redisHelpers }) {
@@ -50,10 +106,9 @@ async function getRRToken({ redisHelpers }) {
return cached.accessToken;
}
// TODO: Implement real RR auth flow here.
// Stub: use env var or a fixed dev token
// TODO: Replace with real RR auth call. For now, fallback to env.
const accessToken = process.env.RR_FAKE_TOKEN || "rr-dev-token";
// Set an artificial 55-minute expiry (adjust to real value)
// Artificial ~55m expiry (adjust to actual token TTL)
const expiresAt = Date.now() + 55 * 60 * 1000;
await redisHelpers.setSessionData(
@@ -69,41 +124,44 @@ async function getRRToken({ redisHelpers }) {
message: error?.message,
stack: error?.stack
});
// In absolute worst case, return a stub so dev environments keep moving
// Keep local dev moving even if cache errors
return process.env.RR_FAKE_TOKEN || "rr-dev-token";
}
}
/**
* 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 to append to URL as path (no leading slash needed)
* @param {Array<[string,string]>} [args.requestSearchParams] - tuples of [key, value] for query
* @param {string} [args.pathParams] - string appended to URL (no leading slash)
* @param {Array<[string,string]>} [args.requestSearchParams] - pairs converted to query params
* @returns {string}
*/
function constructFullUrl({ url, pathParams = "", requestSearchParams = [] }) {
// normalize single trailing slash
// normalize: ensure exactly one trailing slash on base
url = url.replace(/\/+$/, "/");
const fullPath = pathParams ? `${url}${pathParams}` : url;
const searchParams = new URLSearchParams(requestSearchParams).toString();
return searchParams ? `${fullPath}?${searchParams}` : fullPath;
const query = new URLSearchParams(requestSearchParams).toString();
return query ? `${fullPath}?${query}` : fullPath;
}
/**
* 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
* @param {string} args.access_token
* @param {string} args.reqId
* @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>}
*/
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);
await sleep((delayMeta?.checkStatusAfterSeconds || 2) * 1000);
const statusUrl = delayMeta?._links?.status?.href;
if (!statusUrl) {
@@ -137,7 +195,7 @@ function sleep(ms) {
}
/**
* Core caller. Mirrors Fortellis' MakeFortellisCall shape so you can reuse flow.
* Core caller. Mirrors Fortellis' MakeFortellisCall shape so we can reuse flow.
*
* @param {Object} args
* @param {string} args.apiName - logical name (used in logs/errors)
@@ -147,7 +205,7 @@ function sleep(ms) {
* @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] added as query params
* @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
@@ -169,7 +227,6 @@ async function MakeRRCall({
const fullUrl = constructFullUrl({ url, pathParams: requestPathParams, requestSearchParams });
const reqId = uuid();
const idempotencyKey = uuid();
const access_token = await getRRToken({ redisHelpers });
if (debug) {
@@ -179,12 +236,11 @@ async function MakeRRCall({
url: fullUrl,
jobid,
reqId,
body
body: safeLogJson(body)
});
}
try {
let resp;
const baseHeaders = {
Authorization: `Bearer ${access_token}`,
"X-Request-Id": reqId,
@@ -192,6 +248,7 @@ async function MakeRRCall({
...headers
};
let resp;
switch ((type || "post").toLowerCase()) {
case "get":
resp = await axios.get(fullUrl, { headers: baseHeaders });
@@ -200,6 +257,7 @@ async function MakeRRCall({
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":
@@ -268,9 +326,9 @@ async function MakeRRCall({
}
/**
* Keep action registry centralized so upstream modules can import a single map.
* Replace the base URLs with real RR/Rome endpoints as you finalize the integration.
* You can also split this into per-domain registries once you know the layout.
* 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
@@ -312,7 +370,7 @@ const RRActions = {
UpdateCustomer: {
apiName: "RR Update Customer",
url: isProduction
? "https://rr.example.com/customer/v1/" // append /{id} if required
? "https://rr.example.com/customer/v1/" // append /{id} if required by spec
: "https://rr-uat.example.com/customer/v1/",
type: "put"
},
@@ -323,11 +381,12 @@ const RRActions = {
: "https://rr-uat.example.com/customer/v1/",
type: "get"
},
QueryCustomerByName: {
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)",
@@ -336,12 +395,14 @@ const RRActions = {
: "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",
@@ -413,9 +474,15 @@ function safeLogJson(data) {
}
module.exports = {
// core helpers
MakeRRCall,
RRActions,
getRRToken,
constructFullUrl,
DelayedCallback
DelayedCallback,
// parity exports required by other RR modules
getTransactionType,
defaultRRTTL,
RRCacheEnums
};