1104 lines
33 KiB
JavaScript
1104 lines
33 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com";
|
|
const ORACLE_COOKIE_URLS = [
|
|
"https://eeho.fa.us2.oraclecloud.com/",
|
|
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome",
|
|
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome",
|
|
"https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay"
|
|
];
|
|
const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F";
|
|
const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-";
|
|
const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list";
|
|
const COOKIE_REFRESH_TIMEOUT_MS = 15000;
|
|
const COOKIE_REFRESH_POLL_MS = 250;
|
|
const OPPTY_PRODUCTS_CACHE_KEY = "opportunitiesExtension.opptyProductsCache.v1";
|
|
const OPPTY_PRODUCTS_PAGE_LIMIT = 15;
|
|
const OPPTY_PRODUCTS_MAX_PAGES = 250;
|
|
const OPPTY_PRODUCTS_CONCURRENCY = 4;
|
|
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService";
|
|
const ACCOUNT_SUMMARY_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-account/service/accountsummary";
|
|
const ACCOUNT_PLANS_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/account/accountPlans";
|
|
const ACCOUNT_PLANS_PAYLOAD = {
|
|
hierarchy: "single",
|
|
products: "all",
|
|
language: "english",
|
|
planStatus: [
|
|
"active",
|
|
"awaiting_provisioning",
|
|
"awaiting_provisioning_error",
|
|
"draft",
|
|
"expired",
|
|
"final_billing",
|
|
"onhold",
|
|
"provisioned",
|
|
"signed",
|
|
"superseded",
|
|
"superseded_pending_final_billing",
|
|
"suspended",
|
|
"terminated",
|
|
"terminated_pending_final_billing"
|
|
]
|
|
};
|
|
const DEBUG_BODY_LIMIT = 100000;
|
|
const OPPTY_PRODUCTS_FIELDS = [
|
|
"ProductType", "Description", "InventoryItemId", "ProdGroupName", "ProdGroupId", "OwnerLockAsgnFlag",
|
|
"Quantity", "RecurTypeCode", "RevnAmountCurcyCode", "UnitPrice", "RevnAmount", "PriceTypeCode",
|
|
"EffectiveDate", "Name1", "NonRecurringRevenue", "OpportunityOwnerPartyName", "OpportunityOwnerResourcePartyId",
|
|
"OptyId", "OptyNumber", "PartyName2", "PrTerritoryVersionId", "PrTerritoryVersionIdForManual",
|
|
"RecurEndDate", "RecurFrequencyCode", "RecurNumberPeriods", "RecurRevenue", "ResourcePartyId", "RevnId",
|
|
"RevnNumber", "SplitPercent", "SalesCreditTypeCode", "SplitTypeCode", "TerrOwnerPartyName", "UpsideAmount",
|
|
"UsageRevenue", "StatusCode", "BUOrgId", "TypeCode", "WinProb", "ForecastType_c", "ARRLocalCurrency_c",
|
|
"CPQLastSyncDate_c", "CPQOperationType_c", "ProdGLID_c", "ProposalNumber_c", "QuoteNumber_c",
|
|
"ServicesPeriod_c", "CPQIntLastUpdatedBy_c", "CPQUpdatedAmount_c", "CPQUpdatedQuantity_c",
|
|
"CPQUpdatedRevnType_c", "CPQUpdatedServicePeriod_c", "CPQUpdatedStatus_c", "OrderNumber_c", "PrevAmount_c",
|
|
"PrevQuantity_c", "PrevRevenueType_c", "PrevServicePeriod_c", "PrevStatus_c", "SubscriptionID_c",
|
|
"WorkloadName_c", "ConsumptionStartDate_c", "RampMonths_c"
|
|
];
|
|
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
|
|
let productsCachePromise = null;
|
|
let productsCacheWritePromise = Promise.resolve();
|
|
const inFlightProducts = new Map();
|
|
|
|
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
if (!message || !message.type) {
|
|
return false;
|
|
}
|
|
|
|
if (message.type === "opportunitiesExtension.getXsrfToken") {
|
|
getXsrfTokenCookie()
|
|
.then((result) => {
|
|
sendResponse({
|
|
ok: true,
|
|
cookieName: result.cookie ? result.cookie.name : "",
|
|
token: result.cookie ? result.cookie.value : "",
|
|
matchedCookieNames: result.matchedCookieNames,
|
|
lookupDetails: result.lookupDetails
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
sendResponse({
|
|
ok: false,
|
|
cookieName: "",
|
|
token: "",
|
|
matchedCookieNames: [],
|
|
lookupDetails: [],
|
|
error: error.message || "Unable to read cookies."
|
|
});
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
if (message.type === "opportunitiesExtension.refreshXsrfCookie") {
|
|
refreshXsrfCookie(sender)
|
|
.then(sendResponse)
|
|
.catch((error) => {
|
|
sendResponse({
|
|
ok: false,
|
|
refreshed: false,
|
|
cookieName: "",
|
|
durationMs: 0,
|
|
error: error.message || "Unable to refresh XSRF cookie."
|
|
});
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
if (message.type === "opportunitiesExtension.requestOpptyProducts") {
|
|
requestOpptyProducts(message, sender)
|
|
.then(sendResponse)
|
|
.catch((error) => {
|
|
sendResponse({
|
|
ok: false,
|
|
requestId: message.requestId || "",
|
|
error: error.message || "Unable to load opportunity products."
|
|
});
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
if (message.type === "opportunitiesExtension.requestConsumerTokenService") {
|
|
requestConsumerTokenService()
|
|
.then(sendResponse)
|
|
.catch((error) => {
|
|
sendResponse({
|
|
ok: false,
|
|
error: error.message || "Unable to load consumer token service.",
|
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
|
});
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
if (message.type === "opportunitiesExtension.requestAccountPlans") {
|
|
requestAccountPlans(message)
|
|
.then(sendResponse)
|
|
.catch((error) => {
|
|
sendResponse({
|
|
ok: false,
|
|
error: error.message || "Unable to load account plans.",
|
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
|
});
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
async function requestConsumerTokenService() {
|
|
const debugRequests = [];
|
|
|
|
try {
|
|
const tokenResponse = await fetchJson(CUSTOMER_TOKEN_SERVICE_URL, {
|
|
method: "GET",
|
|
credentials: "include",
|
|
cache: "no-store",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Cache-Control": "no-cache",
|
|
Pragma: "no-cache"
|
|
}
|
|
}, "requestConsumerTokenService", debugRequests);
|
|
const accountSummaryAuthorization = extractTokenServiceAuthorization(
|
|
tokenResponse,
|
|
"ACCP-TS",
|
|
"account-ti-authorization"
|
|
);
|
|
const accountPlansAuthorization = extractTokenServiceAuthorization(
|
|
tokenResponse,
|
|
"OCICONS-TS",
|
|
"g2m-authorization"
|
|
);
|
|
const tokenRequestDebugEntry = debugRequests[debugRequests.length - 1];
|
|
|
|
if (tokenRequestDebugEntry) {
|
|
tokenRequestDebugEntry.metadata = {
|
|
...tokenRequestDebugEntry.metadata,
|
|
accountSummaryTokenService: "ACCP-TS",
|
|
accountSummaryAuthorizationHeader: accountSummaryAuthorization.name || "(missing)",
|
|
accountSummaryAuthorizationValue: accountSummaryAuthorization.value
|
|
? `(present, ${accountSummaryAuthorization.value.length} characters)`
|
|
: "(missing)",
|
|
accountSummaryAuthorizationExpires: accountSummaryAuthorization.expires || "(not provided)",
|
|
accountPlansTokenService: "OCICONS-TS",
|
|
accountPlansAuthorizationHeader: accountPlansAuthorization.name || "(missing)",
|
|
accountPlansAuthorizationValue: accountPlansAuthorization.value
|
|
? `(present, ${accountPlansAuthorization.value.length} characters)`
|
|
: "(missing)",
|
|
accountPlansAuthorizationExpires: accountPlansAuthorization.expires || "(not provided)"
|
|
};
|
|
}
|
|
|
|
if (!accountSummaryAuthorization.value) {
|
|
throw new Error("ACCP-TS uiaasHeader.value was not found in requestConsumerTokenService.");
|
|
}
|
|
|
|
if (!accountPlansAuthorization.value) {
|
|
throw new Error("OCICONS-TS uiaasHeader.value was not found in requestConsumerTokenService.");
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
authorizations: {
|
|
accountSummary: accountSummaryAuthorization,
|
|
accountPlans: accountPlansAuthorization
|
|
},
|
|
debugRequests
|
|
};
|
|
} catch (error) {
|
|
error.debugRequests = debugRequests;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function requestAccountPlans(message) {
|
|
const partyNumber = typeof message.partyNumber === "string" ? message.partyNumber.trim() : "";
|
|
const accountSummaryAuthorization = normalizeMessageAuthorization(
|
|
message.authorizations && message.authorizations.accountSummary,
|
|
"account-ti-authorization"
|
|
);
|
|
const accountPlansAuthorization = normalizeMessageAuthorization(
|
|
message.authorizations && message.authorizations.accountPlans,
|
|
"g2m-authorization"
|
|
);
|
|
const debugRequests = [];
|
|
|
|
if (!partyNumber) {
|
|
throw new Error("Customer Party Number is required.");
|
|
}
|
|
|
|
if (!accountSummaryAuthorization.value) {
|
|
throw new Error("ACCP-TS authorization is unavailable. requestConsumerTokenService must complete first.");
|
|
}
|
|
|
|
if (!accountPlansAuthorization.value) {
|
|
throw new Error("OCICONS-TS authorization is unavailable. requestConsumerTokenService must complete first.");
|
|
}
|
|
|
|
try {
|
|
const accountSummary = await fetchJson(ACCOUNT_SUMMARY_URL, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: {
|
|
Accept: "application/json",
|
|
[accountSummaryAuthorization.name]: accountSummaryAuthorization.value,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify([{ key: "regId", value: partyNumber }])
|
|
}, "requestAccountSummary", debugRequests);
|
|
const accountPlanId = findAccountPlanId(accountSummary);
|
|
|
|
if (!accountPlanId) {
|
|
throw new Error("Account plan id was not found in requestAccountSummary.");
|
|
}
|
|
|
|
const accountPlansResponse = await fetchJson(`${ACCOUNT_PLANS_URL}/${encodeURIComponent(String(accountPlanId))}`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: {
|
|
Accept: "application/json",
|
|
[accountPlansAuthorization.name]: accountPlansAuthorization.value,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(ACCOUNT_PLANS_PAYLOAD)
|
|
}, "requestAccountPlans", debugRequests);
|
|
|
|
return {
|
|
ok: true,
|
|
accountPlanId: String(accountPlanId),
|
|
plans: extractAccountPlans(accountPlansResponse),
|
|
debugRequests
|
|
};
|
|
} catch (error) {
|
|
error.debugRequests = debugRequests;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function normalizeMessageAuthorization(authorization, fallbackHeaderName) {
|
|
const headerName = authorization && typeof authorization.name === "string"
|
|
? authorization.name.trim()
|
|
: "";
|
|
const headerValue = authorization && typeof authorization.value === "string"
|
|
? authorization.value.trim()
|
|
: "";
|
|
|
|
return {
|
|
name: headerName || fallbackHeaderName,
|
|
value: headerValue
|
|
};
|
|
}
|
|
|
|
function extractTokenServiceAuthorization(response, tokenServiceName, fallbackHeaderName) {
|
|
const tokenService = Array.isArray(response)
|
|
? response.find((entry) => entry && entry.tokenService === tokenServiceName)
|
|
: findNestedObject(response, (entry) => entry.tokenService === tokenServiceName);
|
|
const authorizationHeader = tokenService && tokenService.uiaasHeader;
|
|
const headerName = authorizationHeader && typeof authorizationHeader.label === "string"
|
|
? authorizationHeader.label.trim()
|
|
: "";
|
|
const headerValue = authorizationHeader && typeof authorizationHeader.value === "string"
|
|
? authorizationHeader.value.trim()
|
|
: "";
|
|
|
|
return {
|
|
name: headerName || fallbackHeaderName,
|
|
value: headerValue,
|
|
expires: authorizationHeader && authorizationHeader.expires
|
|
? String(authorizationHeader.expires)
|
|
: ""
|
|
};
|
|
}
|
|
|
|
async function fetchJson(url, options, requestName, debugRequests = null) {
|
|
const startedAt = new Date();
|
|
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
|
|
let response;
|
|
|
|
try {
|
|
response = await fetch(url, options);
|
|
} catch (error) {
|
|
completeBackgroundDebugEntry(debugEntry, null, "", error.message || "Network request failed.");
|
|
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
|
throw error;
|
|
}
|
|
|
|
const responsePayload = await readResponsePayload(response);
|
|
const responseBody = createDebugBody(responsePayload.text);
|
|
completeBackgroundDebugEntry(debugEntry, response, responseBody, "");
|
|
debugEntry.metadata.responseContentType = response.headers.get("content-type") || "(not provided)";
|
|
debugEntry.metadata.responseLength = String(responsePayload.text.length);
|
|
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`${requestName} failed with status ${response.status}.`);
|
|
}
|
|
|
|
if (responsePayload.error) {
|
|
throw new Error(`${requestName} response body could not be read.`);
|
|
}
|
|
|
|
return parseJsonResponse(responsePayload.text, requestName);
|
|
}
|
|
|
|
function createBackgroundDebugEntry(requestName, url, options, startedAt) {
|
|
return {
|
|
name: requestName,
|
|
method: options.method || "GET",
|
|
url,
|
|
status: "pending",
|
|
startedAt: startedAt.toISOString(),
|
|
completedAt: null,
|
|
durationMs: null,
|
|
httpStatus: null,
|
|
statusText: "",
|
|
requestHeaders: normalizeDebugHeaders(options.headers),
|
|
requestBody: formatDebugRequestBody(options.body),
|
|
metadata: {
|
|
executionContext: "background"
|
|
},
|
|
responseHeaders: {},
|
|
responseBody: "",
|
|
responseBodyTruncated: false,
|
|
error: ""
|
|
};
|
|
}
|
|
|
|
function completeBackgroundDebugEntry(entry, response, responseBody, errorMessage) {
|
|
const completedAt = new Date();
|
|
entry.completedAt = completedAt.toISOString();
|
|
entry.durationMs = completedAt.getTime() - new Date(entry.startedAt).getTime();
|
|
entry.error = errorMessage;
|
|
|
|
if (!response) {
|
|
entry.status = "failed";
|
|
return;
|
|
}
|
|
|
|
entry.status = response.ok ? "success" : "failed";
|
|
entry.httpStatus = response.status;
|
|
entry.statusText = response.statusText || "";
|
|
entry.responseHeaders = headersToObject(response.headers);
|
|
entry.responseBody = responseBody.value;
|
|
entry.responseBodyTruncated = responseBody.truncated;
|
|
}
|
|
|
|
function appendBackgroundDebugEntry(debugRequests, entry) {
|
|
if (Array.isArray(debugRequests)) {
|
|
debugRequests.push(entry);
|
|
}
|
|
}
|
|
|
|
async function readResponsePayload(response) {
|
|
try {
|
|
return {
|
|
text: await response.text(),
|
|
error: ""
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
text: `Unable to read response body: ${error.message || "unknown error"}`,
|
|
error: error.message || "unknown error"
|
|
};
|
|
}
|
|
}
|
|
|
|
function parseJsonResponse(text, requestName) {
|
|
const normalizedText = String(text)
|
|
.replace(/^\uFEFF/, "")
|
|
.trimStart()
|
|
.replace(/^\)\]\}',?\s*/, "")
|
|
.replace(/^while\s*\(1\);\s*/, "");
|
|
|
|
try {
|
|
return JSON.parse(normalizedText);
|
|
} catch (error) {
|
|
throw new Error(`${requestName} returned an invalid JSON response.`);
|
|
}
|
|
}
|
|
|
|
function createDebugBody(text) {
|
|
if (text.length > DEBUG_BODY_LIMIT) {
|
|
return {
|
|
value: `${text.slice(0, DEBUG_BODY_LIMIT)}\n... truncated ${text.length - DEBUG_BODY_LIMIT} characters`,
|
|
truncated: true
|
|
};
|
|
}
|
|
|
|
return {
|
|
value: text || "(empty response body)",
|
|
truncated: false
|
|
};
|
|
}
|
|
|
|
function normalizeDebugHeaders(headers) {
|
|
const normalized = {};
|
|
|
|
Object.entries(headers || {}).forEach(([key, value]) => {
|
|
normalized[key] = String(value);
|
|
});
|
|
return normalized;
|
|
}
|
|
|
|
function headersToObject(headers) {
|
|
const values = {};
|
|
|
|
if (headers && typeof headers.forEach === "function") {
|
|
headers.forEach((value, key) => {
|
|
values[key] = value;
|
|
});
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function formatDebugRequestBody(body) {
|
|
if (!body) {
|
|
return "";
|
|
}
|
|
|
|
try {
|
|
return JSON.stringify(JSON.parse(body), null, 2);
|
|
} catch (error) {
|
|
return String(body);
|
|
}
|
|
}
|
|
|
|
function findNestedObject(value, predicate, visited = new Set()) {
|
|
if (!value || typeof value !== "object" || visited.has(value)) {
|
|
return null;
|
|
}
|
|
|
|
visited.add(value);
|
|
|
|
if (!Array.isArray(value) && predicate(value)) {
|
|
return value;
|
|
}
|
|
|
|
for (const child of Object.values(value)) {
|
|
const match = findNestedObject(child, predicate, visited);
|
|
|
|
if (match) {
|
|
return match;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function findAccountPlanId(response) {
|
|
const account = findNestedObject(response, (value) => (
|
|
Object.prototype.hasOwnProperty.call(value, "id")
|
|
&& value.id !== null
|
|
&& value.id !== undefined
|
|
&& value.id !== ""
|
|
));
|
|
return account ? account.id : "";
|
|
}
|
|
|
|
function extractAccountPlans(response) {
|
|
const plans = [];
|
|
const visited = new Set();
|
|
|
|
const visit = (value) => {
|
|
if (!value || typeof value !== "object" || visited.has(value)) {
|
|
return;
|
|
}
|
|
|
|
visited.add(value);
|
|
|
|
if (!Array.isArray(value) && (
|
|
Object.prototype.hasOwnProperty.call(value, "planType")
|
|
|| Object.prototype.hasOwnProperty.call(value, "subPlanNum")
|
|
)) {
|
|
plans.push(value);
|
|
return;
|
|
}
|
|
|
|
Object.values(value).forEach(visit);
|
|
};
|
|
|
|
visit(response);
|
|
return plans;
|
|
}
|
|
|
|
async function requestOpptyProducts(message, sender) {
|
|
const requestId = typeof message.requestId === "string" ? message.requestId : "";
|
|
const accessToken = typeof message.accessToken === "string" ? message.accessToken : "";
|
|
const cacheMaxAgeMs = normalizeCacheMaxAge(message.cacheMaxAgeMs);
|
|
const optyNumbers = Array.from(new Set(
|
|
(Array.isArray(message.optyNumbers) ? message.optyNumbers : [])
|
|
.filter(Boolean)
|
|
.map(String)
|
|
));
|
|
|
|
if (!requestId || !accessToken) {
|
|
throw new Error("A request id and access token are required for requestOpptyProducts.");
|
|
}
|
|
|
|
let nextIndex = 0;
|
|
let completed = 0;
|
|
let failed = 0;
|
|
let cacheHits = 0;
|
|
|
|
const runWorker = async () => {
|
|
while (nextIndex < optyNumbers.length) {
|
|
const index = nextIndex;
|
|
nextIndex += 1;
|
|
const optyNumber = optyNumbers[index];
|
|
|
|
try {
|
|
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs);
|
|
completed += 1;
|
|
|
|
if (result.fromCache) {
|
|
cacheHits += 1;
|
|
}
|
|
|
|
await sendProductsProgress(sender, {
|
|
type: "opportunitiesExtension.opptyProductsProgress",
|
|
requestId,
|
|
state: "running",
|
|
optyNumber,
|
|
items: result.items,
|
|
fromCache: result.fromCache,
|
|
cachedAt: result.cachedAt,
|
|
completed,
|
|
total: optyNumbers.length,
|
|
failed,
|
|
cacheHits
|
|
});
|
|
} catch (error) {
|
|
completed += 1;
|
|
failed += 1;
|
|
await sendProductsProgress(sender, {
|
|
type: "opportunitiesExtension.opptyProductsProgress",
|
|
requestId,
|
|
state: "running",
|
|
optyNumber,
|
|
items: [],
|
|
error: error.message || "Unable to load products.",
|
|
completed,
|
|
total: optyNumbers.length,
|
|
failed,
|
|
cacheHits
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
|
|
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
|
|
|
const state = failed > 0 ? "complete-with-errors" : "complete";
|
|
await sendProductsProgress(sender, {
|
|
type: "opportunitiesExtension.opptyProductsProgress",
|
|
requestId,
|
|
state,
|
|
completed,
|
|
total: optyNumbers.length,
|
|
failed,
|
|
cacheHits
|
|
});
|
|
|
|
return {
|
|
ok: failed === 0,
|
|
requestId,
|
|
completed,
|
|
total: optyNumbers.length,
|
|
failed,
|
|
cacheHits
|
|
};
|
|
}
|
|
|
|
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) {
|
|
const cache = await getProductsCache();
|
|
const cachedEntry = cache[optyNumber];
|
|
|
|
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
|
return { items: cachedEntry.items, fromCache: true, cachedAt: Number(cachedEntry.fetchedAt) };
|
|
}
|
|
|
|
if (!inFlightProducts.has(optyNumber)) {
|
|
const request = fetchAllOpptyProducts(optyNumber, accessToken)
|
|
.then(async (items) => {
|
|
const fetchedAt = Date.now();
|
|
cache[optyNumber] = {
|
|
fetchedAt,
|
|
items
|
|
};
|
|
await saveProductsCache(cache).catch(() => {});
|
|
return { items, fetchedAt };
|
|
})
|
|
.finally(() => {
|
|
inFlightProducts.delete(optyNumber);
|
|
});
|
|
inFlightProducts.set(optyNumber, request);
|
|
}
|
|
|
|
const fetchedResult = await inFlightProducts.get(optyNumber);
|
|
return {
|
|
items: fetchedResult.items,
|
|
cachedAt: fetchedResult.fetchedAt,
|
|
fromCache: false
|
|
};
|
|
}
|
|
|
|
async function fetchAllOpptyProducts(optyNumber, accessToken) {
|
|
let offset = 0;
|
|
let page = 0;
|
|
const items = [];
|
|
|
|
while (page < OPPTY_PRODUCTS_MAX_PAGES) {
|
|
const response = await fetch(createOpptyProductsUrl(optyNumber, offset), {
|
|
method: "GET",
|
|
credentials: "include",
|
|
headers: {
|
|
Accept: "application/json",
|
|
Authorization: `Bearer ${accessToken}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`requestOpptyProducts failed with status ${response.status}.`);
|
|
}
|
|
|
|
const responseData = await response.json();
|
|
const pageItems = Array.isArray(responseData.items) ? responseData.items : [];
|
|
items.push(...pageItems);
|
|
page += 1;
|
|
|
|
if (!responseData.hasMore) {
|
|
return items;
|
|
}
|
|
|
|
const responseOffset = Number(responseData.offset);
|
|
const responseCount = Number(responseData.count);
|
|
const receivedCount = Number.isFinite(responseCount) ? responseCount : pageItems.length;
|
|
const currentOffset = Number.isFinite(responseOffset) ? responseOffset : offset;
|
|
|
|
if (receivedCount <= 0) {
|
|
throw new Error("requestOpptyProducts returned hasMore without additional results.");
|
|
}
|
|
|
|
offset = currentOffset + receivedCount;
|
|
}
|
|
|
|
throw new Error("requestOpptyProducts exceeded the pagination safety limit.");
|
|
}
|
|
|
|
function createOpptyProductsUrl(optyNumber, offset) {
|
|
const url = new URL(`https://${ORACLE_DOMAIN}/crmRestApi/rest/rv:be91c002-2e5d-4ed2-a37e-e0c837bf141f/en/11.13.18.05:9/opportunities/${encodeURIComponent(optyNumber)}/child/ChildRevenue`);
|
|
url.searchParams.set("onlyData", "true");
|
|
url.searchParams.set("q", "(SplitTypeCode!='DETAILCHILDSPLIT') AND (RecurTypeCode!='CHILDRECUR')");
|
|
url.searchParams.set("totalResults", "false");
|
|
url.searchParams.set("fields", OPPTY_PRODUCTS_FIELDS.join(","));
|
|
url.searchParams.set("orderBy", "CreationDate:desc");
|
|
url.searchParams.set("limit", String(OPPTY_PRODUCTS_PAGE_LIMIT));
|
|
url.searchParams.set("offset", String(offset));
|
|
return url.toString();
|
|
}
|
|
|
|
function normalizeCacheMaxAge(value) {
|
|
const maxAge = Number(value);
|
|
const defaultMaxAge = 24 * 60 * 60 * 1000;
|
|
const maximumMaxAge = 30 * 24 * 60 * 60 * 1000;
|
|
return Number.isFinite(maxAge) && maxAge > 0
|
|
? Math.min(maxAge, maximumMaxAge)
|
|
: defaultMaxAge;
|
|
}
|
|
|
|
async function getProductsCache() {
|
|
if (!productsCachePromise) {
|
|
productsCachePromise = storageLocalGet(OPPTY_PRODUCTS_CACHE_KEY)
|
|
.then((result) => result && typeof result[OPPTY_PRODUCTS_CACHE_KEY] === "object"
|
|
? result[OPPTY_PRODUCTS_CACHE_KEY]
|
|
: {})
|
|
.catch(() => ({}));
|
|
}
|
|
|
|
return productsCachePromise;
|
|
}
|
|
|
|
function saveProductsCache(cache) {
|
|
productsCacheWritePromise = productsCacheWritePromise
|
|
.catch(() => {})
|
|
.then(() => storageLocalSet({ [OPPTY_PRODUCTS_CACHE_KEY]: cache }));
|
|
return productsCacheWritePromise;
|
|
}
|
|
|
|
function sendProductsProgress(sender, message) {
|
|
if (!sender || !sender.tab || !Number.isInteger(sender.tab.id)) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
const options = Number.isInteger(sender.frameId) ? { frameId: sender.frameId } : undefined;
|
|
return tabsSendMessage(sender.tab.id, message, options).catch(() => {});
|
|
}
|
|
|
|
async function refreshXsrfCookie(sender) {
|
|
const startedAt = Date.now();
|
|
const previous = (await getXsrfTokenCookie()).cookie;
|
|
const createProperties = {
|
|
url: OPPORTUNITIES_LIST_URL,
|
|
active: false
|
|
};
|
|
|
|
if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) {
|
|
createProperties.windowId = sender.tab.windowId;
|
|
}
|
|
|
|
if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) {
|
|
createProperties.cookieStoreId = sender.tab.cookieStoreId;
|
|
}
|
|
|
|
let refreshTab;
|
|
|
|
try {
|
|
refreshTab = await tabsCreate(createProperties);
|
|
const current = await waitForXsrfCookieChange(previous);
|
|
const refreshed = hasCookieChanged(previous, current);
|
|
|
|
return {
|
|
ok: Boolean(current),
|
|
refreshed,
|
|
cookieName: current ? current.name : "",
|
|
durationMs: Date.now() - startedAt,
|
|
error: refreshed ? "" : "XSRF cookie was not created or changed before timeout."
|
|
};
|
|
} finally {
|
|
if (refreshTab && Number.isInteger(refreshTab.id)) {
|
|
try {
|
|
await tabsRemove(refreshTab.id);
|
|
} catch (error) {
|
|
// The temporary tab may already have been closed.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function waitForXsrfCookieChange(previousCookie) {
|
|
const deadline = Date.now() + COOKIE_REFRESH_TIMEOUT_MS;
|
|
let currentCookie = null;
|
|
|
|
while (Date.now() < deadline) {
|
|
currentCookie = (await getXsrfTokenCookie()).cookie;
|
|
|
|
if (hasCookieChanged(previousCookie, currentCookie)) {
|
|
return currentCookie;
|
|
}
|
|
|
|
await wait(COOKIE_REFRESH_POLL_MS);
|
|
}
|
|
|
|
return currentCookie;
|
|
}
|
|
|
|
function hasCookieChanged(previousCookie, currentCookie) {
|
|
if (!currentCookie || !currentCookie.value) {
|
|
return false;
|
|
}
|
|
|
|
if (!previousCookie) {
|
|
return true;
|
|
}
|
|
|
|
return previousCookie.name !== currentCookie.name
|
|
|| previousCookie.value !== currentCookie.value
|
|
|| previousCookie.expirationDate !== currentCookie.expirationDate;
|
|
}
|
|
|
|
function supportsFirefoxCookieStoreTabs() {
|
|
return Boolean(
|
|
runtimeApi.runtime
|
|
&& typeof runtimeApi.runtime.getBrowserInfo === "function"
|
|
);
|
|
}
|
|
|
|
function tabsCreate(details) {
|
|
if (runtimeApi.tabs.create.length <= 1) {
|
|
return runtimeApi.tabs.create(details);
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
runtimeApi.tabs.create(details, (tab) => {
|
|
const lastError = runtimeApi.runtime.lastError;
|
|
|
|
if (lastError) {
|
|
reject(new Error(lastError.message));
|
|
return;
|
|
}
|
|
|
|
resolve(tab);
|
|
});
|
|
});
|
|
}
|
|
|
|
function tabsRemove(tabId) {
|
|
if (runtimeApi.tabs.remove.length <= 1) {
|
|
return runtimeApi.tabs.remove(tabId);
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
runtimeApi.tabs.remove(tabId, () => {
|
|
const lastError = runtimeApi.runtime.lastError;
|
|
|
|
if (lastError) {
|
|
reject(new Error(lastError.message));
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function tabsSendMessage(tabId, message, options) {
|
|
if (typeof browser !== "undefined") {
|
|
return options
|
|
? runtimeApi.tabs.sendMessage(tabId, message, options)
|
|
: runtimeApi.tabs.sendMessage(tabId, message);
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const callback = (response) => {
|
|
const lastError = runtimeApi.runtime.lastError;
|
|
|
|
if (lastError) {
|
|
reject(new Error(lastError.message));
|
|
return;
|
|
}
|
|
|
|
resolve(response);
|
|
};
|
|
|
|
if (options) {
|
|
runtimeApi.tabs.sendMessage(tabId, message, options, callback);
|
|
} else {
|
|
runtimeApi.tabs.sendMessage(tabId, message, callback);
|
|
}
|
|
});
|
|
}
|
|
|
|
function storageLocalGet(key) {
|
|
if (typeof browser !== "undefined") {
|
|
return runtimeApi.storage.local.get(key);
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
runtimeApi.storage.local.get(key, (result) => {
|
|
const lastError = runtimeApi.runtime.lastError;
|
|
|
|
if (lastError) {
|
|
reject(new Error(lastError.message));
|
|
return;
|
|
}
|
|
|
|
resolve(result);
|
|
});
|
|
});
|
|
}
|
|
|
|
function storageLocalSet(value) {
|
|
if (typeof browser !== "undefined") {
|
|
return runtimeApi.storage.local.set(value);
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
runtimeApi.storage.local.set(value, () => {
|
|
const lastError = runtimeApi.runtime.lastError;
|
|
|
|
if (lastError) {
|
|
reject(new Error(lastError.message));
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function wait(durationMs) {
|
|
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
}
|
|
|
|
async function getXsrfTokenCookie() {
|
|
const lookupDetails = [];
|
|
const allCookies = [];
|
|
const stores = await getCookieStores(lookupDetails);
|
|
|
|
for (const store of stores) {
|
|
const exactCookie = await getExactCookieFromUrls(store.id, lookupDetails);
|
|
|
|
if (exactCookie) {
|
|
allCookies.push(exactCookie);
|
|
}
|
|
}
|
|
|
|
for (const store of stores) {
|
|
await collectCookies({
|
|
name: XSRF_COOKIE_NAME,
|
|
storeId: store.id
|
|
}, allCookies, lookupDetails);
|
|
}
|
|
|
|
for (const store of stores) {
|
|
await collectCookies({
|
|
domain: ORACLE_DOMAIN,
|
|
storeId: store.id
|
|
}, allCookies, lookupDetails);
|
|
|
|
await collectCookies({
|
|
domain: `.${ORACLE_DOMAIN}`,
|
|
storeId: store.id
|
|
}, allCookies, lookupDetails);
|
|
}
|
|
|
|
for (const store of stores) {
|
|
for (const url of ORACLE_COOKIE_URLS) {
|
|
await collectCookies({
|
|
url,
|
|
storeId: store.id
|
|
}, allCookies, lookupDetails);
|
|
}
|
|
}
|
|
|
|
const uniqueCookies = dedupeCookies(allCookies);
|
|
const xsrfCookies = uniqueCookies.filter((cookie) => cookie.name.startsWith(XSRF_COOKIE_PREFIX));
|
|
const exactCookie = xsrfCookies.find((cookie) => cookie.name === XSRF_COOKIE_NAME);
|
|
const hostCookie = xsrfCookies.find((cookie) => cookie.domain === ORACLE_DOMAIN || cookie.domain === `.${ORACLE_DOMAIN}`);
|
|
|
|
return {
|
|
cookie: exactCookie || hostCookie || xsrfCookies[0] || null,
|
|
matchedCookieNames: xsrfCookies.map((cookie) => `${cookie.name} (${cookie.domain}${cookie.path})`),
|
|
lookupDetails
|
|
};
|
|
}
|
|
|
|
async function getExactCookieFromUrls(storeId, lookupDetails) {
|
|
for (const url of ORACLE_COOKIE_URLS) {
|
|
const cookie = await cookiesGet({
|
|
url,
|
|
name: XSRF_COOKIE_NAME,
|
|
storeId
|
|
});
|
|
|
|
lookupDetails.push(`${JSON.stringify({ url, name: XSRF_COOKIE_NAME, storeId })} => ${cookie ? "found" : "not found"}`);
|
|
|
|
if (cookie) {
|
|
return cookie;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function getCookieStores(lookupDetails) {
|
|
try {
|
|
const stores = await cookiesGetAllCookieStores();
|
|
lookupDetails.push(`getAllCookieStores => ${stores.length} store(s)`);
|
|
return stores.length ? stores : [{ id: undefined }];
|
|
} catch (error) {
|
|
lookupDetails.push(`getAllCookieStores => unavailable (${error.message || "unknown error"})`);
|
|
return [{ id: undefined }];
|
|
}
|
|
}
|
|
|
|
async function collectCookies(details, target, lookupDetails) {
|
|
const cleanDetails = removeUndefinedValues(details);
|
|
const cookies = await cookiesGetAll(cleanDetails);
|
|
target.push(...cookies);
|
|
lookupDetails.push(`${JSON.stringify(cleanDetails)} => ${cookies.length} cookie(s)`);
|
|
}
|
|
|
|
function dedupeCookies(cookies) {
|
|
const seen = new Set();
|
|
|
|
return cookies.filter((cookie) => {
|
|
const key = `${cookie.name}|${cookie.domain}|${cookie.path}|${cookie.storeId || ""}`;
|
|
|
|
if (seen.has(key)) {
|
|
return false;
|
|
}
|
|
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function cookiesGetAll(details) {
|
|
if (runtimeApi.cookies.getAll.length <= 1) {
|
|
return runtimeApi.cookies.getAll(details);
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
runtimeApi.cookies.getAll(details, (cookies) => {
|
|
const lastError = runtimeApi.runtime.lastError;
|
|
|
|
if (lastError) {
|
|
reject(new Error(lastError.message));
|
|
return;
|
|
}
|
|
|
|
resolve(cookies);
|
|
});
|
|
});
|
|
}
|
|
|
|
function cookiesGet(details) {
|
|
const cleanDetails = removeUndefinedValues(details);
|
|
|
|
if (runtimeApi.cookies.get.length <= 1) {
|
|
return runtimeApi.cookies.get(cleanDetails);
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
runtimeApi.cookies.get(cleanDetails, (cookie) => {
|
|
const lastError = runtimeApi.runtime.lastError;
|
|
|
|
if (lastError) {
|
|
reject(new Error(lastError.message));
|
|
return;
|
|
}
|
|
|
|
resolve(cookie);
|
|
});
|
|
});
|
|
}
|
|
|
|
function cookiesGetAllCookieStores() {
|
|
if (!runtimeApi.cookies.getAllCookieStores) {
|
|
return Promise.resolve([{ id: undefined }]);
|
|
}
|
|
|
|
if (runtimeApi.cookies.getAllCookieStores.length === 0) {
|
|
return runtimeApi.cookies.getAllCookieStores();
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
runtimeApi.cookies.getAllCookieStores((stores) => {
|
|
const lastError = runtimeApi.runtime.lastError;
|
|
|
|
if (lastError) {
|
|
reject(new Error(lastError.message));
|
|
return;
|
|
}
|
|
|
|
resolve(stores);
|
|
});
|
|
});
|
|
}
|
|
|
|
function removeUndefinedValues(details) {
|
|
return Object.fromEntries(
|
|
Object.entries(details).filter((entry) => entry[1] !== undefined)
|
|
);
|
|
}
|
|
})();
|