const path = require("path"); require("dotenv").config({ path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) }); // const CalcualteAllocations = require("../cdk/cdk-calculate-allocations").default; const CreateFortellisLogEvent = require("./fortellis-logger"); const logger = require("../utils/logger"); const uuid = require("uuid").v4; const AxiosLib = require("axios").default; const axios = AxiosLib.create(); const axiosCurlirize = require("axios-curlirize").default; // Custom error class for Fortellis API errors class FortellisApiError extends Error { constructor(message, details) { super(message); this.name = "FortellisApiError"; this.reqId = details.reqId; this.url = details.url; this.apiName = details.apiName; this.errorData = details.errorData; this.errorStatus = details.errorStatus; this.errorStatusText = details.errorStatusText; this.originalError = details.originalError; } } axiosCurlirize(axios, (_result, _err) => { //Left intentionally blank. We don't want to console.log. We handle logging the cURL in MakeFortellisCall once completed. }); const getTransactionType = (jobid) => `fortellis:${jobid}`; const defaultFortellisTTL = 60 * 60; async function GetAuthToken() { //Done with Authorization Code Flow //https://docs.fortellis.io/docs/tutorials/solution-integration/authorization-code-flow/ //TODO: This should get stored in the redis cache and only be refreshed when it expires. const { data: { access_token, expires_in, token_type } } = await axios.post( process.env.FORTELLIS_AUTH_URL, {}, { auth: { username: process.env.FORTELLIS_KEY, password: process.env.FORTELLIS_SECRET }, params: { grant_type: "client_credentials", scope: "anonymous" } } ); return access_token; } async function FetchSubscriptions({ redisHelpers, socket, jobid, SubscriptionObject }) { try { const { setSessionTransactionData, getSessionTransactionData } = redisHelpers; //Get Subscription ID from Transaction Envelope const { SubscriptionID } = SubscriptionObject ? SubscriptionObject : await getSessionTransactionData(socket.id, getTransactionType(jobid), `txEnvelope`); if (!SubscriptionID) { throw new Error("Subscription ID not found in transaction envelope."); } //Check to See if the subscription meta is in the Redis Cache. const SubscriptionMetaFromCache = await getSessionTransactionData( socket.id, getTransactionType(jobid), FortellisCacheEnums.SubscriptionMeta ); // If it is, return it. if (SubscriptionMetaFromCache) { return SubscriptionMetaFromCache; } else { const access_token = await GetAuthToken(); const subscriptions = await axios.get(FortellisActions.GetSubscription.url, { headers: { Authorization: `Bearer ${access_token}` }, logRequest: false }); const SubscriptionMeta = subscriptions.data.subscriptions.find((s) => s.subscriptionId === SubscriptionID); if (setSessionTransactionData) { await setSessionTransactionData( socket.id, getTransactionType(jobid), FortellisCacheEnums.SubscriptionMeta, SubscriptionMeta, defaultFortellisTTL ); } return SubscriptionMeta; } } catch (error) { CreateFortellisLogEvent(socket, "ERROR", `Error fetching subscription metadata.`, { error: error.message, stack: error.stack }); } } async function GetDepartmentId({ apiName, debug = false, SubscriptionMeta, overrideDepartmentId }) { if (!apiName) throw new Error("apiName not provided. Unable to get department without apiName."); if (debug) { console.log("API Names & Departments "); console.log("==========="); console.log(JSON.stringify(SubscriptionMeta.apiDmsInfo, null, 4)); console.log("==========="); } const departmentIds = SubscriptionMeta.apiDmsInfo //Get the subscription object. .find((info) => info.name === apiName)?.departments; //Departments are categorized by API name and have an array of departments. if (overrideDepartmentId) { return departmentIds && departmentIds.find(d => d.id === overrideDepartmentId)?.id } else { return departmentIds && departmentIds[0] && departmentIds[0].id; //TODO: This makes the assumption that there is only 1 department. } } //Highest level function call to make a call to fortellis. This should be the only call required, and it will handle all the logic for making the call. async function MakeFortellisCall({ apiName, url, headers = {}, body = {}, type = "post", debug = false, requestPathParams, requestSearchParams = [], //Array of key/value strings like [["key", "value"]] jobid, redisHelpers, socket, SubscriptionObject, //This is used because of the get make models to bypass all of the redis calls. overrideDepartmentId }) { //const { setSessionTransactionData, getSessionTransactionData } = redisHelpers; const fullUrl = constructFullUrl({ url, pathParams: requestPathParams, requestSearchParams }); if (debug) console.log(`Executing ${type} to ${fullUrl}`); const ReqId = uuid(); const access_token = await GetAuthToken(); const SubscriptionMeta = await FetchSubscriptions({ redisHelpers, socket, jobid, SubscriptionObject }); const DepartmentId = await GetDepartmentId({ apiName, debug, SubscriptionMeta, overrideDepartmentId }); if (debug) { console.log( `ReqID: ${ReqId} | SubscriptionID: ${SubscriptionMeta.subscriptionId} | DepartmentId: ${DepartmentId}` ); console.log(`Body Contents: ${JSON.stringify(body, null, 4)}`); } try { let result; switch (type) { case "post": default: result = await axios.post(fullUrl, body, { headers: { Authorization: `Bearer ${access_token}`, "Subscription-Id": SubscriptionMeta.subscriptionId, "Request-Id": ReqId, "Content-Type": "application/json", Accept: "application/json", ...(DepartmentId && { "Department-Id": DepartmentId }), ...headers } }); break; case "get": result = await axios.get(fullUrl, { headers: { Authorization: `Bearer ${access_token}`, "Subscription-Id": SubscriptionMeta.subscriptionId, "Request-Id": ReqId, Accept: "application/json", "Department-Id": DepartmentId, ...headers } }); break; case "put": result = await axios.put(fullUrl, body, { headers: { Authorization: `Bearer ${access_token}`, "Subscription-Id": SubscriptionMeta.subscriptionId, "Request-Id": ReqId, Accept: "application/json", "Content-Type": "application/json", "Department-Id": DepartmentId, ...headers } }); break; } if (debug) { console.log(`ReqID: ${ReqId} Data`); console.log(JSON.stringify(result.data, null, 4)); } if (result.data.checkStatusAfterSeconds) { return DelayedCallback({ delayMeta: result.data, access_token, SubscriptionID: SubscriptionMeta.subscriptionId, ReqId, departmentIds: DepartmentId }); } logger.log( "fortellis-log-event-json", "DEBUG", socket?.user?.email, jobid, { requestcurl: result.config.curlCommand, reqid: result.config.headers["Request-Id"] || null, subscriptionId: result.config.headers["Subscription-Id"] || null, resultdata: result.data, resultStatus: result.status }, ); return result.data; } catch (error) { const errorDetails = { reqId: ReqId, url: fullUrl, apiName, errorData: error.response?.data, errorStatus: error.response?.status, errorStatusText: error.response?.statusText, originalError: error }; logger.log( "fortellis-log-event-error", "ERROR", socket?.user?.email, socket?.recordid, { wsmessage: "",//message, curl: error.config.curl.curlCommand, reqid: error.request.headers["Request-Id"] || null, subscriptionId: error.request.headers["Subscription-Id"] || null, }, true ); throw new FortellisApiError(`Fortellis API call failed for ${apiName}: ${error.message}`, errorDetails); } } //Some Fortellis calls return a batch result that isn't ready immediately. //This function will check the status of the call and wait until it is ready. //It will try 5 times before giving up. async function DelayedCallback({ delayMeta, access_token, SubscriptionID, ReqId, departmentIds }) { for (let index = 0; index < 5; index++) { await sleep(delayMeta.checkStatusAfterSeconds * 1000); //Check to see if the call is ready. const statusResult = await axios.get(delayMeta._links.status.href, { headers: { Authorization: `Bearer ${access_token}`, "Subscription-Id": SubscriptionID, "Request-Id": ReqId, "Department-Id": departmentIds[0].id } }); //TODO: Add a check if the status result is not ready, to try again. if (statusResult.data.status === "complete") { //This may have to check again if it isn't ready. const batchResult = await axios.get(statusResult.data._links.result.href, { headers: { Authorization: `Bearer ${access_token}`, "Subscription-Id": SubscriptionID, "Request-Id": ReqId //"Department-Id": departmentIds[0].id } }); return batchResult; } else { return "Error!!! Still need to implement batch waiting."; } } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } const isProduction = process.env.NODE_ENV === "production"; //Get requests should have the trailing slash as they are used that way in the calls. const FortellisActions = { GetSubscription: { url: isProduction ? "https://subscriptions.fortellis.io/v1/solution/subscriptions" : "https://subscriptions.fortellis.io/v1/solution/subscriptions", type: "get", apiName: "Fortellis Get Subscriptions" }, QueryVehicles: { url: isProduction ? "https://api.fortellis.io/cdkdrive/service/v1/vehicles/" : "https://api.fortellis.io/cdk-test/cdkdrive/service/v1/vehicles/", type: "get", apiName: "Service Vehicle - Query Vehicles" }, GetMakeModel: { url: isProduction ? "https://api.fortellis.io/cdk/drive/makemodel/v2/bulk" : "https://api.fortellis.io/cdk-test/drive/makemodel/v2", type: "get", apiName: "CDK Drive Get Make Model Lite" }, GetVehicleId: { url: isProduction ? "https://api.fortellis.io/cdk/drive/service-vehicle-mgmt/v2/vehicle-ids/" //Request path params of vins : "https://api.fortellis.io/cdk-test/drive/service-vehicle-mgmt/v2/vehicle-ids/", type: "get", apiName: "CDK Drive Post Service Vehicle" }, GetVehicleById: { url: isProduction ? "https://api.fortellis.io/cdk/drive/service-vehicle-mgmt/v2/" //Request path params of vehicleId : "https://api.fortellis.io/cdk-test/drive/service-vehicle-mgmt/v2/", type: "get", apiName: "CDK Drive Post Service Vehicle" }, QueryCustomerByName: { url: isProduction ? "https://api.fortellis.io/cdk/drive/customerpost/v1/search" : "https://api.fortellis.io/cdk-test/drive/customerpost/v1/search", type: "get", apiName: "CDK Drive Post Customer" }, ReadCustomer: { url: isProduction ? "https://api.fortellis.io/cdk/drive/customerpost/v1/" //Customer ID is request param. : "https://api.fortellis.io/cdk-test/drive/customerpost/v1/", type: "get", apiName: "CDK Drive Post Customer" }, CreateCustomer: { url: isProduction ? "https://api.fortellis.io/cdk/drive/customerpost/v1/" : "https://api.fortellis.io/cdk-test/drive/customerpost/v1/", type: "post", apiName: "CDK Drive Post Customer" }, InsertVehicle: { url: isProduction ? "https://api.fortellis.io/cdk/drive/service-vehicle-mgmt/v2/" : "https://api.fortellis.io/cdk-test/drive/service-vehicle-mgmt/v2/", type: "post", apiName: "CDK Drive Post Service Vehicle" }, UpdateVehicle: { url: isProduction ? "https://api.fortellis.io/cdk/drive/service-vehicle-mgmt/v2/" : "https://api.fortellis.io/cdk-test/drive/service-vehicle-mgmt/v2/", type: "put", apiName: "CDK Drive Post Service Vehicle" }, GetCOA: { type: "get", apiName: "CDK Drive Post Accounts GL WIP", url: `https://api.fortellis.io/cdk-test/drive/chartofaccounts/v2/bulk/`, waitForResult: true }, StartWip: { url: isProduction ? "https://api.fortellis.io/cdk/drive/glpost/startWIP" : "https://api.fortellis.io/cdk-test/drive/glpost/startWIP", type: "post", apiName: "CDK Drive Post Accounts GL" }, TranBatchWip: { url: isProduction ? "https://api.fortellis.io/cdk/drive/glpost/transBatchWIP" : "https://api.fortellis.io/cdk-test/drive/glpost/transBatchWIP", type: "post", apiName: "CDK Drive Post Accounts GL" }, PostBatchWip: { url: isProduction ? "https://api.fortellis.io/cdk/drive/glpost/postBatchWIP" : "https://api.fortellis.io/cdk-test/drive/glpost/postBatchWIP", type: "post", apiName: "CDK Drive Post Accounts GL" }, DeleteTranWip: { url: isProduction ? "https://api.fortellis.io/cdk/drive/glpost/postWIP" : "https://api.fortellis.io/cdk-test/drive/glpost/postWIP", type: "post", apiName: "CDK Drive Post Accounts GL" }, QueryErrorWip: { url: isProduction ? "https://api.fortellis.io/cdk/drive/glpost/errWIP/" : "https://api.fortellis.io/cdk-test/drive/glpost/errWIP/", type: "get", apiName: "CDK Drive Post Accounts GL" }, ServiceHistoryInsert: { url: isProduction ? "https://api.fortellis.io/cdk/drive/post/service-vehicle-history-mgmt/v2/" : "https://api.fortellis.io/cdk-test/drive/post/service-vehicle-history-mgmt/v2/", type: "post", apiName: "CDK Drive Post Service Vehicle History" } }; const FortellisCacheEnums = { txEnvelope: "txEnvelope", DMSBatchTxn: "DMSBatchTxn", SubscriptionMeta: "SubscriptionMeta", DepartmentId: "DepartmentId", JobData: "JobData", DMSVid: "DMSVid", DMSVeh: "DMSVeh", DMSVehCustomer: "DMSVehCustomer", DMSCustList: "DMSCustList", DMSCust: "DMSCust", selectedCustomerId: "selectedCustomerId", DMSTransHeader: "DMSTransHeader", transWips: "transWips", DmsBatchTxnPost: "DmsBatchTxnPost", DMSVehHistory: "DMSVehHistory" }; function constructFullUrl({ url, pathParams = "", requestSearchParams = [] }) { // Ensure the base URL ends with a single "/" url = url.replace(/\/+$/, "/"); const fullPath = pathParams ? `${url}${pathParams}` : url; const searchParams = new URLSearchParams(requestSearchParams).toString(); const fullUrl = searchParams ? `${fullPath}?${searchParams}` : fullPath; return fullUrl; } module.exports = { GetAuthToken, FortellisCacheEnums, MakeFortellisCall, FortellisActions, getTransactionType, defaultFortellisTTL, FortellisApiError, GetDepartmentId };