489 lines
15 KiB
JavaScript
489 lines
15 KiB
JavaScript
/**
|
||
* 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)
|
||
*
|
||
* What’s 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();
|
||
const axiosCurlirize = require("axios-curlirize").default;
|
||
|
||
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 */) => {
|
||
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 don’t 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";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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}
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 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>}
|
||
*/
|
||
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);
|
||
|
||
const statusUrl = delayMeta?._links?.status?.href;
|
||
if (!statusUrl) {
|
||
return { error: "No status URL provided by RR batch envelope." };
|
||
}
|
||
|
||
const statusResult = await axios.get(statusUrl, {
|
||
headers: {
|
||
Authorization: `Bearer ${access_token}`,
|
||
"X-Request-Id": reqId
|
||
}
|
||
});
|
||
|
||
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;
|
||
}
|
||
}
|
||
return { error: "Batch result still not complete after max attempts." };
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
/**
|
||
* Core caller. Mirrors Fortellis' MakeFortellisCall shape so we can reuse flow.
|
||
*
|
||
* @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>}
|
||
*/
|
||
async function MakeRRCall({
|
||
apiName,
|
||
url,
|
||
headers = {},
|
||
body = {},
|
||
type = "post",
|
||
debug = true,
|
||
requestPathParams,
|
||
requestSearchParams = [],
|
||
jobid,
|
||
redisHelpers,
|
||
socket
|
||
}) {
|
||
const fullUrl = constructFullUrl({ url, pathParams: requestPathParams, requestSearchParams });
|
||
const reqId = uuid();
|
||
const idempotencyKey = uuid();
|
||
const access_token = await getRRToken({ redisHelpers });
|
||
|
||
if (debug) {
|
||
logger.log("rr-call", "DEBUG", socket?.user?.email, null, {
|
||
apiName,
|
||
type,
|
||
url: fullUrl,
|
||
jobid,
|
||
reqId,
|
||
body: safeLogJson(body)
|
||
});
|
||
}
|
||
|
||
try {
|
||
const baseHeaders = {
|
||
Authorization: `Bearer ${access_token}`,
|
||
"X-Request-Id": reqId,
|
||
"Idempotency-Key": idempotencyKey,
|
||
...headers
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
if (debug) {
|
||
logger.log("rr-response", "DEBUG", socket?.user?.email, null, {
|
||
apiName,
|
||
reqId,
|
||
data: safeLogJson(resp?.data)
|
||
});
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
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,
|
||
headers,
|
||
body,
|
||
type,
|
||
debug,
|
||
requestPathParams,
|
||
requestSearchParams,
|
||
jobid,
|
||
redisHelpers,
|
||
socket
|
||
});
|
||
}
|
||
|
||
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"
|
||
},
|
||
|
||
// 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]";
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
// core helpers
|
||
MakeRRCall,
|
||
RRActions,
|
||
getRRToken,
|
||
constructFullUrl,
|
||
DelayedCallback,
|
||
|
||
// parity exports required by other RR modules
|
||
getTransactionType,
|
||
defaultRRTTL,
|
||
RRCacheEnums
|
||
};
|