const path = require("path"); const fs = require("fs").promises; require("dotenv").config({ path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) }); const client = require("../graphql-client/graphql-client").client; // const CalcualteAllocations = require("../cdk/cdk-calculate-allocations").default; const CreateFortellisLogEvent = require("./fortellis-logger"); const logger = require("../utils/logger"); const { INSERT_IOEVENT } = require("../graphql-client/queries"); 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/ 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 (!SubscriptionMeta) { throw new Error(`Subscription metadata not found for 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 }); throw error; } } async function GetDepartmentId({ apiName, debug = false, SubscriptionMeta, overrideDepartmentId }) { if (!apiName) throw new Error("apiName not provided. Unable to get department without apiName."); if (!SubscriptionMeta || !Array.isArray(SubscriptionMeta.apiDmsInfo)) { throw new Error("Subscription metadata missing apiDmsInfo."); } 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)}`); } //Write to the IOEvent Log await WriteToIOEventLog({ apiName, fullUrl, logger, socket, useremail: socket.user?.email, type, bodyshopid: "" //bodyshopid: job.bodyshop.id, }) 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)); } // await writeFortellisLogToFile({ // timestamp: new Date().toISOString(), // reqId: ReqId, // url: fullUrl, // request: // { // requestcurl: result.config.curlCommand, // reqid: result.config.headers["Request-Id"] || null, // subscriptionId: result.config.headers["Subscription-Id"] || null, // resultdata: result.data, // resultStatus: result.status // }, // user: socket?.user?.email, // jobid: socket?.recordid // }); 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 }, ); if (result.data.checkStatusAfterSeconds) { return DelayedCallback({ delayMeta: result.data, access_token, SubscriptionID: SubscriptionMeta.subscriptionId, ReqId, departmentIds: DepartmentId, jobid, socket }); } 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 }; // await writeFortellisLogToFile({ // timestamp: new Date().toISOString(), // reqId: ReqId, // url: fullUrl, // request: // { // requestcurl: error.config.curlCommand, // reqid: error.config.headers["Request-Id"] || null, // subscriptionId: error.config.headers["Subscription-Id"] || null, // resultdata: error.message, // resultStatus: error.status // }, // user: socket?.user?.email, // jobid: socket?.recordid // }); logger.log( "fortellis-log-event-error", "ERROR", socket?.user?.email, socket?.recordid, { requestcurl: error.config.curlCommand, reqid: error.config.headers["Request-Id"] || null, subscriptionId: error.config.headers["Subscription-Id"] || null, resultdata: error.message, resultStatus: error.status }, true ); throw new FortellisApiError(`Fortellis API call failed for ${apiName}: ${error.message} | ${errorDetails?.errorData?.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, jobid, socket }) { 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 } }); logger.log( "fortellis-log-event-json-DelayedCallback", "DEBUG", socket?.user?.email, jobid, { requestcurl: batchResult.config.curlCommand, reqid: batchResult.config.headers["Request-Id"] || null, subscriptionId: batchResult.config.headers["Subscription-Id"] || null, resultdata: batchResult.data, resultStatus: batchResult.status }, ); // await writeFortellisLogToFile({ // timestamp: new Date().toISOString(), // reqId: ReqId, // url: statusResult.data._links.result.href, // request: // { // requestcurl: batchResult.config.curlCommand, // reqid: batchResult.config.headers["Request-Id"] || null, // subscriptionId: batchResult.config.headers["Subscription-Id"] || null, // resultdata: batchResult.data, // resultStatus: batchResult.status // }, // }); return batchResult; } else { return "Error!!! Still need to implement batch waiting."; } } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function writeFortellisLogToFile(logObject) { //The was only used for the certification. Commented out in case of future need. try { const logsDir = path.join(process.cwd(), 'logs'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `fortellis-${timestamp}.json`; const filepath = path.join(logsDir, filename); console.log(`[writeFortellisLogToFile] Writing to: ${filepath}`); console.log(`[writeFortellisLogToFile] process.cwd(): ${process.cwd()}`); // Ensure logs directory exists await fs.mkdir(logsDir, { recursive: true }); // Write log object to file await fs.writeFile(filepath, JSON.stringify(logObject, null, 2), 'utf8'); console.log(`Fortellis log written to: ${filepath}`,); } catch (err) { console.error('Failed to write Fortellis log to file:', err); } } async function WriteToIOEventLog({ apiName, type, fullUrl, bodyshopid, useremail, logger, socket }) { try { await client.request(INSERT_IOEVENT, { event: { operationname: `fortellis-${apiName}-${type}`, //time, //dbevent, env: isProduction ? "production" : "test", variables: { fullUrl, type, apiName }, bodyshopid: socket.bodyshopId, useremail } }); } catch (error) { logger.log( "fortellis-tracking-error", "ERROR", socket?.user?.email, socket?.recordid, { operationname: `fortellis-${apiName}-`, //time, //dbevent, env: isProduction ? "production" : "test", variables: {}, bodyshopid, useremail, error: error.message, stack: error.stack }, true ); } } 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/bulk", 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 Get Chart of Accounts", url: isProduction ? "https://api.fortellis.io/cdk/drive/chartofaccounts/v2/bulk" : "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 };