432 lines
13 KiB
JavaScript
432 lines
13 KiB
JavaScript
// server/rr/rr-helpers.js
|
|
|
|
/**
|
|
* RR (Reynolds & Reynolds) helper module
|
|
* - Loads env (.env.{NODE_ENV})
|
|
* - Provides token retrieval + 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
|
|
*/
|
|
|
|
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");
|
|
|
|
// Optional curl logging (handy while scaffolding)
|
|
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);
|
|
});
|
|
|
|
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).
|
|
*/
|
|
const RR_PROVIDER_TOKEN_BUCKET = "rr:provider-token";
|
|
const RR_PROVIDER_TOKEN_FIELD = "token";
|
|
|
|
/**
|
|
* Fetch an RR access token. Replace with the real auth call when available.
|
|
* @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: Implement real RR auth flow here.
|
|
// Stub: use env var or a fixed dev token
|
|
const accessToken = process.env.RR_FAKE_TOKEN || "rr-dev-token";
|
|
// Set an artificial 55-minute expiry (adjust to real value)
|
|
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
|
|
});
|
|
// In absolute worst case, return a stub so dev environments keep moving
|
|
return process.env.RR_FAKE_TOKEN || "rr-dev-token";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Construct a full URL including optional path segment and query params.
|
|
* @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
|
|
* @returns {string}
|
|
*/
|
|
function constructFullUrl({ url, pathParams = "", requestSearchParams = [] }) {
|
|
// normalize single trailing slash
|
|
url = url.replace(/\/+$/, "/");
|
|
const fullPath = pathParams ? `${url}${pathParams}` : url;
|
|
const searchParams = new URLSearchParams(requestSearchParams).toString();
|
|
return searchParams ? `${fullPath}?${searchParams}` : 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
|
|
* @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 you 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] added as 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
|
|
});
|
|
}
|
|
|
|
try {
|
|
let resp;
|
|
const baseHeaders = {
|
|
Authorization: `Bearer ${access_token}`,
|
|
"X-Request-Id": reqId,
|
|
"Idempotency-Key": idempotencyKey,
|
|
...headers
|
|
};
|
|
|
|
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":
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
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
|
|
: "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"
|
|
},
|
|
QueryCustomerByName: {
|
|
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 = {
|
|
MakeRRCall,
|
|
RRActions,
|
|
getRRToken,
|
|
constructFullUrl,
|
|
DelayedCallback
|
|
};
|