Compare commits
10 Commits
db382b63f2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b1a35430a | |||
| f0fb99d1ef | |||
| e4cfb3fa9c | |||
| 7fef10abd3 | |||
| 6f8b2beea3 | |||
| 911da359e3 | |||
| 9c6c5ff690 | |||
| 9cb58809c8 | |||
| dfc84c64ee | |||
| 0b854e23d2 |
48
README.md
48
README.md
@@ -1,42 +1,52 @@
|
||||
# Opportunities Extension
|
||||
# Opportunities Extension 1.0.0
|
||||
|
||||
Extensao de navegador para adicionar o atalho "Opportunities Extension" nas paginas Oracle Fusion permitidas.
|
||||
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table.
|
||||
|
||||
## Estrutura de desenvolvimento
|
||||
## Development Structure
|
||||
|
||||
- `src/content.js`: script unico mantido no projeto.
|
||||
- `scripts/build.mjs`: gera os pacotes especificos para cada navegador.
|
||||
- `dist/chromium/`: output para Google Chrome e Microsoft Edge.
|
||||
- `dist/firefox/`: output para Firefox.
|
||||
- `src/content.js`: single source file maintained by the project.
|
||||
- `scripts/build.mjs`: generates browser-specific packages.
|
||||
- `dist/chromium/`: output for Google Chrome and Microsoft Edge.
|
||||
- `dist/firefox/`: output for Firefox.
|
||||
|
||||
## Build
|
||||
|
||||
Use o Node.js:
|
||||
Use Node.js:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
O build recria `dist/chromium` e `dist/firefox` a partir da mesma fonte.
|
||||
The build recreates `dist/chromium` and `dist/firefox` from the same source.
|
||||
|
||||
## Como carregar em modo desenvolvimento
|
||||
## Release 1.0.0
|
||||
|
||||
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
|
||||
- Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators.
|
||||
- Opportunity type view preferences persisted in the browser.
|
||||
- Background product retrieval with configurable caching, progress tracking, and expandable product details.
|
||||
- Cloud consumption account-plan lookup from each opportunity, displayed in a tabbed detail modal.
|
||||
- Light and dark themes based on Oracle Redwood.
|
||||
- Separate outputs for Chromium/Edge and Firefox.
|
||||
|
||||
## Loading in Development Mode
|
||||
|
||||
### Chrome ou Microsoft Edge
|
||||
|
||||
1. Abra `chrome://extensions` ou `edge://extensions`.
|
||||
2. Ative o modo de desenvolvedor.
|
||||
3. Clique em "Carregar sem compactacao".
|
||||
4. Selecione a pasta `dist/chromium`.
|
||||
1. Open `chrome://extensions` or `edge://extensions`.
|
||||
2. Enable Developer mode.
|
||||
3. Click **Load unpacked**.
|
||||
4. Select the `dist/chromium` folder.
|
||||
|
||||
### Firefox
|
||||
|
||||
1. Abra `about:debugging#/runtime/this-firefox`.
|
||||
2. Clique em "Carregar extensao temporaria".
|
||||
3. Selecione o arquivo `dist/firefox/manifest.json`.
|
||||
1. Open `about:debugging#/runtime/this-firefox`.
|
||||
2. Click **Load Temporary Add-on**.
|
||||
3. Select the `dist/firefox/manifest.json` file.
|
||||
|
||||
## Paginas atendidas
|
||||
## Supported Pages
|
||||
|
||||
- `https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome`
|
||||
- `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome`
|
||||
|
||||
Ao encontrar o grupo `#yourapps_groupNode_sales`, a extensao adiciona o tile antes do item `.flat-grid-cell.flat-grid-cell-addicon`. Ao clicar no tile, exibe o alerta `Deu certo`.
|
||||
When it finds the `#yourapps_groupNode_sales` group, the extension adds the tile before the `.flat-grid-cell.flat-grid-cell-addicon` item. Clicking the tile opens the opportunities interface.
|
||||
|
||||
960
dist/chromium/background.js
vendored
960
dist/chromium/background.js
vendored
@@ -10,13 +10,70 @@
|
||||
];
|
||||
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 CONSUMPTION_TREND_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/trend";
|
||||
const CONSUMPTION_TREND_REQUESTS = {
|
||||
quarterly: "requestConsumptionServiceQuarterly",
|
||||
monthly: "requestConsumptionServiceMonthly",
|
||||
weekly: "requestConsumptionServiceWeekly",
|
||||
daily: "requestConsumptionServiceDaily"
|
||||
};
|
||||
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 !== "opportunitiesExtension.getXsrfToken") {
|
||||
if (!message || !message.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.type === "opportunitiesExtension.getXsrfToken") {
|
||||
getXsrfTokenCookie()
|
||||
.then((result) => {
|
||||
sendResponse({
|
||||
@@ -39,7 +96,908 @@
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (message.type === "opportunitiesExtension.requestConsumptionTrend") {
|
||||
requestConsumptionTrend(message)
|
||||
.then(sendResponse)
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
ok: false,
|
||||
error: error.message || "Unable to load consumption trend.",
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestConsumptionTrend(message) {
|
||||
const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : "";
|
||||
const period = typeof message.period === "string" ? message.period.toLowerCase() : "";
|
||||
const requestName = CONSUMPTION_TREND_REQUESTS[period];
|
||||
const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization");
|
||||
const debugRequests = [];
|
||||
|
||||
if (!subPlanNum) {
|
||||
throw new Error("Sub plan number is required for the consumption trend.");
|
||||
}
|
||||
|
||||
if (!requestName) {
|
||||
throw new Error(`Unsupported consumption trend period: ${period || "(missing)"}.`);
|
||||
}
|
||||
|
||||
if (!authorization.value) {
|
||||
throw new Error("OCICONS-TS authorization is unavailable for the consumption trend.");
|
||||
}
|
||||
|
||||
const url = `${CONSUMPTION_TREND_URL}/${encodeURIComponent(subPlanNum)}?quarter=All&timePeriod=${period}`;
|
||||
|
||||
try {
|
||||
const response = await fetchJson(url, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"G2m-Authorization": authorization.value
|
||||
}
|
||||
}, requestName, debugRequests);
|
||||
const items = Array.isArray(response)
|
||||
? response
|
||||
: Array.isArray(response && response.items)
|
||||
? response.items
|
||||
: [];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
subPlanNum,
|
||||
period,
|
||||
items,
|
||||
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 = [];
|
||||
|
||||
3529
dist/chromium/content.js
vendored
3529
dist/chromium/content.js
vendored
File diff suppressed because it is too large
Load Diff
8
dist/chromium/manifest.json
vendored
8
dist/chromium/manifest.json
vendored
@@ -2,12 +2,14 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Opportunities Extension",
|
||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"permissions": [
|
||||
"cookies"
|
||||
"cookies",
|
||||
"storage"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://eeho.fa.us2.oraclecloud.com/*"
|
||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||
"https://spa.oracle.com/*"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
|
||||
960
dist/firefox/background.js
vendored
960
dist/firefox/background.js
vendored
@@ -10,13 +10,70 @@
|
||||
];
|
||||
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 CONSUMPTION_TREND_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/trend";
|
||||
const CONSUMPTION_TREND_REQUESTS = {
|
||||
quarterly: "requestConsumptionServiceQuarterly",
|
||||
monthly: "requestConsumptionServiceMonthly",
|
||||
weekly: "requestConsumptionServiceWeekly",
|
||||
daily: "requestConsumptionServiceDaily"
|
||||
};
|
||||
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 !== "opportunitiesExtension.getXsrfToken") {
|
||||
if (!message || !message.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.type === "opportunitiesExtension.getXsrfToken") {
|
||||
getXsrfTokenCookie()
|
||||
.then((result) => {
|
||||
sendResponse({
|
||||
@@ -39,7 +96,908 @@
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (message.type === "opportunitiesExtension.requestConsumptionTrend") {
|
||||
requestConsumptionTrend(message)
|
||||
.then(sendResponse)
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
ok: false,
|
||||
error: error.message || "Unable to load consumption trend.",
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestConsumptionTrend(message) {
|
||||
const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : "";
|
||||
const period = typeof message.period === "string" ? message.period.toLowerCase() : "";
|
||||
const requestName = CONSUMPTION_TREND_REQUESTS[period];
|
||||
const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization");
|
||||
const debugRequests = [];
|
||||
|
||||
if (!subPlanNum) {
|
||||
throw new Error("Sub plan number is required for the consumption trend.");
|
||||
}
|
||||
|
||||
if (!requestName) {
|
||||
throw new Error(`Unsupported consumption trend period: ${period || "(missing)"}.`);
|
||||
}
|
||||
|
||||
if (!authorization.value) {
|
||||
throw new Error("OCICONS-TS authorization is unavailable for the consumption trend.");
|
||||
}
|
||||
|
||||
const url = `${CONSUMPTION_TREND_URL}/${encodeURIComponent(subPlanNum)}?quarter=All&timePeriod=${period}`;
|
||||
|
||||
try {
|
||||
const response = await fetchJson(url, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"G2m-Authorization": authorization.value
|
||||
}
|
||||
}, requestName, debugRequests);
|
||||
const items = Array.isArray(response)
|
||||
? response
|
||||
: Array.isArray(response && response.items)
|
||||
? response.items
|
||||
: [];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
subPlanNum,
|
||||
period,
|
||||
items,
|
||||
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 = [];
|
||||
|
||||
3529
dist/firefox/content.js
vendored
3529
dist/firefox/content.js
vendored
File diff suppressed because it is too large
Load Diff
8
dist/firefox/manifest.json
vendored
8
dist/firefox/manifest.json
vendored
@@ -2,12 +2,14 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Opportunities Extension",
|
||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"permissions": [
|
||||
"cookies"
|
||||
"cookies",
|
||||
"storage"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://eeho.fa.us2.oraclecloud.com/*"
|
||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||
"https://spa.oracle.com/*"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "opportunities-extension",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node scripts/build.mjs",
|
||||
"check": "node --check src/content.js && node scripts/build.mjs --check"
|
||||
"check": "node --check src/content.js && node --check src/background.js && node tests/consumptionTrend.test.cjs && node scripts/build.mjs --check",
|
||||
"test": "node tests/consumptionTrend.test.cjs"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ const baseManifest = {
|
||||
manifest_version: 3,
|
||||
name: "Opportunities Extension",
|
||||
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||
version: "0.1.0",
|
||||
version: "1.0.0",
|
||||
permissions: [
|
||||
"cookies"
|
||||
"cookies",
|
||||
"storage"
|
||||
],
|
||||
host_permissions: [
|
||||
"https://eeho.fa.us2.oraclecloud.com/*"
|
||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||
"https://spa.oracle.com/*"
|
||||
],
|
||||
content_scripts: [
|
||||
{
|
||||
|
||||
@@ -10,13 +10,70 @@
|
||||
];
|
||||
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 CONSUMPTION_TREND_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/trend";
|
||||
const CONSUMPTION_TREND_REQUESTS = {
|
||||
quarterly: "requestConsumptionServiceQuarterly",
|
||||
monthly: "requestConsumptionServiceMonthly",
|
||||
weekly: "requestConsumptionServiceWeekly",
|
||||
daily: "requestConsumptionServiceDaily"
|
||||
};
|
||||
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 !== "opportunitiesExtension.getXsrfToken") {
|
||||
if (!message || !message.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.type === "opportunitiesExtension.getXsrfToken") {
|
||||
getXsrfTokenCookie()
|
||||
.then((result) => {
|
||||
sendResponse({
|
||||
@@ -39,7 +96,908 @@
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (message.type === "opportunitiesExtension.requestConsumptionTrend") {
|
||||
requestConsumptionTrend(message)
|
||||
.then(sendResponse)
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
ok: false,
|
||||
error: error.message || "Unable to load consumption trend.",
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestConsumptionTrend(message) {
|
||||
const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : "";
|
||||
const period = typeof message.period === "string" ? message.period.toLowerCase() : "";
|
||||
const requestName = CONSUMPTION_TREND_REQUESTS[period];
|
||||
const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization");
|
||||
const debugRequests = [];
|
||||
|
||||
if (!subPlanNum) {
|
||||
throw new Error("Sub plan number is required for the consumption trend.");
|
||||
}
|
||||
|
||||
if (!requestName) {
|
||||
throw new Error(`Unsupported consumption trend period: ${period || "(missing)"}.`);
|
||||
}
|
||||
|
||||
if (!authorization.value) {
|
||||
throw new Error("OCICONS-TS authorization is unavailable for the consumption trend.");
|
||||
}
|
||||
|
||||
const url = `${CONSUMPTION_TREND_URL}/${encodeURIComponent(subPlanNum)}?quarter=All&timePeriod=${period}`;
|
||||
|
||||
try {
|
||||
const response = await fetchJson(url, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"G2m-Authorization": authorization.value
|
||||
}
|
||||
}, requestName, debugRequests);
|
||||
const items = Array.isArray(response)
|
||||
? response
|
||||
: Array.isArray(response && response.items)
|
||||
? response.items
|
||||
: [];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
subPlanNum,
|
||||
period,
|
||||
items,
|
||||
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 = [];
|
||||
|
||||
3529
src/content.js
3529
src/content.js
File diff suppressed because it is too large
Load Diff
174
tests/consumptionTrend.test.cjs
Normal file
174
tests/consumptionTrend.test.cjs
Normal file
@@ -0,0 +1,174 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
|
||||
function loadContentHelpers() {
|
||||
const source = fs.readFileSync(path.join(root, "src", "content.js"), "utf8");
|
||||
const marker = "\n if (insertTile()) {";
|
||||
const instrumented = source.replace(marker, `
|
||||
globalThis.__consumptionTrendTest = {
|
||||
normalizeConsumptionTrendItems,
|
||||
formatConsumptionPointLabel,
|
||||
createNiceConsumptionMaximum,
|
||||
calculateAverageConsumption
|
||||
};
|
||||
${marker}`);
|
||||
const context = {
|
||||
chrome: { runtime: { onMessage: { addListener() {} } } },
|
||||
document: { getElementById: () => ({}) },
|
||||
window: {
|
||||
location: {
|
||||
hostname: "eeho.fa.us2.oraclecloud.com",
|
||||
pathname: "/fscmUI/faces/FuseWelcome"
|
||||
}
|
||||
},
|
||||
Intl,
|
||||
Date,
|
||||
Map,
|
||||
Set,
|
||||
Number,
|
||||
String,
|
||||
Array,
|
||||
Object,
|
||||
Math
|
||||
};
|
||||
|
||||
vm.runInNewContext(instrumented, context);
|
||||
return context.__consumptionTrendTest;
|
||||
}
|
||||
|
||||
function loadBackground(fetchCalls) {
|
||||
let listener;
|
||||
const source = fs.readFileSync(path.join(root, "src", "background.js"), "utf8");
|
||||
const headers = {
|
||||
get(name) {
|
||||
return name.toLowerCase() === "content-type" ? "application/json" : null;
|
||||
},
|
||||
forEach(callback) {
|
||||
callback("application/json", "content-type");
|
||||
}
|
||||
};
|
||||
const context = {
|
||||
chrome: {
|
||||
runtime: {
|
||||
onMessage: {
|
||||
addListener(callback) {
|
||||
listener = callback;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
fetch: async (url, options) => {
|
||||
fetchCalls.push({ url, options });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers,
|
||||
text: async () => JSON.stringify([{ fyDate: "2026-09-01", usedAmount: 25 }])
|
||||
};
|
||||
},
|
||||
URL,
|
||||
Date,
|
||||
JSON,
|
||||
Map,
|
||||
Set,
|
||||
Promise,
|
||||
String,
|
||||
Array,
|
||||
Object,
|
||||
Number,
|
||||
Math,
|
||||
setTimeout,
|
||||
clearTimeout
|
||||
};
|
||||
|
||||
vm.runInNewContext(source, context);
|
||||
return listener;
|
||||
}
|
||||
|
||||
function dispatch(listener, message) {
|
||||
return new Promise((resolve) => {
|
||||
const asynchronous = listener(message, {}, resolve);
|
||||
assert.equal(asynchronous, true);
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const helpers = loadContentHelpers();
|
||||
const points = helpers.normalizeConsumptionTrendItems([
|
||||
{
|
||||
fyDate: "2026-08-22",
|
||||
fiscalTime: ["22-Aug-26"],
|
||||
fiscalQtr: "FY27-Q1",
|
||||
usageCategory: "Funded Allocation",
|
||||
usedAmount: 31000,
|
||||
arrCd: 120000,
|
||||
currencyCode: "BRL"
|
||||
},
|
||||
{
|
||||
fyDate: "2026-08-22",
|
||||
fiscalTime: ["22-Aug-26"],
|
||||
usageCategory: "OVERAGE",
|
||||
usedAmount: 4200,
|
||||
currencyCode: "BRL"
|
||||
},
|
||||
{
|
||||
fyDate: "2026-08-15",
|
||||
fiscalTime: ["15-Aug-26"],
|
||||
usageCategory: null,
|
||||
usedAmount: 29000,
|
||||
currencyCode: "BRL"
|
||||
}
|
||||
]);
|
||||
|
||||
assert.equal(points.length, 2);
|
||||
assert.equal(points[0].dateKey, "2026-08-15");
|
||||
assert.equal(points[0].funded, 29000);
|
||||
assert.equal(points[1].funded, 31000);
|
||||
assert.equal(points[1].overage, 4200);
|
||||
assert.equal(points[1].total, 35200);
|
||||
assert.equal(points[1].arr, 120000);
|
||||
assert.equal(helpers.formatConsumptionPointLabel(points[1], "weekly"), "22-Aug-26");
|
||||
assert.equal(helpers.createNiceConsumptionMaximum(35200), 50000);
|
||||
assert.equal(helpers.calculateAverageConsumption(points, true), 29000);
|
||||
assert.equal(helpers.calculateAverageConsumption(points, false), 32100);
|
||||
assert.equal(helpers.calculateAverageConsumption(points, true, new Set(["funded"])), 29000);
|
||||
assert.equal(helpers.calculateAverageConsumption(points, false, new Set(["overage"])), 2100);
|
||||
|
||||
const fetchCalls = [];
|
||||
const listener = loadBackground(fetchCalls);
|
||||
const periods = ["quarterly", "monthly", "weekly", "daily"];
|
||||
|
||||
for (const period of periods) {
|
||||
const response = await dispatch(listener, {
|
||||
type: "opportunitiesExtension.requestConsumptionTrend",
|
||||
subPlanNum: "11276098",
|
||||
period,
|
||||
authorization: { name: "g2m-authorization", value: "ocicons-secret" }
|
||||
});
|
||||
|
||||
assert.equal(response.ok, true);
|
||||
assert.equal(response.period, period);
|
||||
assert.equal(response.items.length, 1);
|
||||
assert.equal(response.debugRequests[0].name, `requestConsumptionService${period[0].toUpperCase()}${period.slice(1)}`);
|
||||
}
|
||||
|
||||
assert.equal(fetchCalls.length, 4);
|
||||
fetchCalls.forEach((call, index) => {
|
||||
assert.equal(call.options.method, "GET");
|
||||
assert.equal(call.options.headers["G2m-Authorization"], "ocicons-secret");
|
||||
assert.match(call.url, /\/11276098\?quarter=All&timePeriod=/);
|
||||
assert.ok(call.url.endsWith(periods[index]));
|
||||
});
|
||||
|
||||
console.log("Consumption trend tests passed.");
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
103
tests/fixtures/consumption-trend.html
vendored
Normal file
103
tests/fixtures/consumption-trend.html
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Consumption Trend Visual Test</title>
|
||||
<style>
|
||||
html, body { width: 100%; min-height: 100%; margin: 0; }
|
||||
body { font-family: Arial, sans-serif; }
|
||||
#opportunities-extension-modal { position: fixed; inset: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="opportunities-extension-modal" class="opportunities-extension-modal" data-theme="light"></div>
|
||||
<script>
|
||||
window.chrome = { runtime: { onMessage: { addListener() {} } } };
|
||||
|
||||
async function initialize() {
|
||||
const source = await fetch("/src/content.js").then((response) => response.text());
|
||||
const marker = /\r?\n if \(insertTile\(\)\) \{/;
|
||||
const exposure = `
|
||||
window.__trendHarness = {
|
||||
ensureStyles: ensureExtensionStyles,
|
||||
createAccountPlanDetails,
|
||||
createConsumptionTrendSection,
|
||||
renderConsumptionTrendChart
|
||||
};
|
||||
return;
|
||||
`;
|
||||
const testableSource = source
|
||||
.replace(" if (!isAllowedPage()) {", " if (false) {")
|
||||
.replace(marker, `${exposure}\n if (insertTile()) {`);
|
||||
window.eval(testableSource);
|
||||
|
||||
const plan = {
|
||||
subPlanNum: "11276098",
|
||||
planType: "Universal Credits",
|
||||
bookingCustomer: "Example Customer",
|
||||
soldToPartyName: "Example Sold To",
|
||||
contractId: "12345678",
|
||||
hdrStatusCode: "ACTIVE",
|
||||
startDate: "2026-06-01",
|
||||
endDate: "2027-05-31"
|
||||
};
|
||||
const items = [
|
||||
["20-Jun-26", 29000, 0], ["27-Jun-26", 30000, 0], ["04-Jul-26", 33000, 0],
|
||||
["11-Jul-26", 28500, 0], ["18-Jul-26", 29500, 0], ["25-Jul-26", 23000, 10000],
|
||||
["01-Aug-26", 0, 29000], ["08-Aug-26", 0, 32000], ["15-Aug-26", 0, 30000],
|
||||
["22-Aug-26", 31000, 4500], ["29-Aug-26", 37000, 0], ["05-Sep-26", 35000, 0],
|
||||
["12-Sep-26", 2000, 0]
|
||||
].flatMap(([label, funded, overage]) => {
|
||||
const [day, month, year] = label.split("-");
|
||||
const monthNumber = { Jun: "06", Jul: "07", Aug: "08", Sep: "09" }[month];
|
||||
const base = {
|
||||
fyDate: `20${year}-${monthNumber}-${day}`,
|
||||
fiscalTime: [label],
|
||||
fiscalQtr: "FY27-Q1",
|
||||
currencyCode: "USD",
|
||||
arrCd: 0
|
||||
};
|
||||
const rows = [{ ...base, usageCategory: "Funded Allocation", usedAmount: funded }];
|
||||
if (overage) rows.push({ ...base, usageCategory: "OVERAGE", usedAmount: overage });
|
||||
return rows;
|
||||
});
|
||||
|
||||
window.__trendHarness.ensureStyles();
|
||||
const overlay = document.getElementById("opportunities-extension-modal");
|
||||
overlay.dataset.theme = new URLSearchParams(window.location.search).get("theme") === "dark"
|
||||
? "dark"
|
||||
: "light";
|
||||
const modal = document.createElement("section");
|
||||
modal.className = "opportunities-extension-account-plans-modal";
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "opportunities-extension-account-plans-dialog";
|
||||
const header = document.createElement("header");
|
||||
header.className = "opportunities-extension-account-plans-header";
|
||||
header.innerHTML = "<div><h2>Cloud Consumption</h2><p>Example customer</p></div>";
|
||||
const content = document.createElement("div");
|
||||
content.className = "opportunities-extension-account-plans-content";
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "opportunities-extension-account-plans-tabs";
|
||||
tabs.innerHTML = '<button class="opportunities-extension-account-plan-tab" aria-selected="true"><span class="opportunities-extension-account-plan-status-indicator" data-active="true"></span>Universal Credits 11276098</button>';
|
||||
const panel = document.createElement("section");
|
||||
panel.className = "opportunities-extension-account-plan-panel";
|
||||
const trend = window.__trendHarness.createConsumptionTrendSection(plan);
|
||||
panel.append(window.__trendHarness.createAccountPlanDetails(plan), trend);
|
||||
content.append(tabs, panel);
|
||||
dialog.append(header, content);
|
||||
modal.append(dialog);
|
||||
overlay.append(modal);
|
||||
window.__trendHarness.renderConsumptionTrendChart(
|
||||
trend,
|
||||
trend.querySelector(".opportunities-extension-consumption-chart"),
|
||||
items,
|
||||
"weekly",
|
||||
plan.subPlanNum
|
||||
);
|
||||
}
|
||||
|
||||
initialize();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user