/** * 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} 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} */ 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} */ 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 };