Files
bodyshop/server/fortellis/fortellis-helpers.js
2025-11-14 10:55:47 -08:00

461 lines
15 KiB
JavaScript

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