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

489 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)
*
* 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();
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 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.
* 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
};