General source code refactoring

This commit is contained in:
2026-09-17 12:07:01 -03:00
parent 4421b28efe
commit ac817a69d4
92 changed files with 18211 additions and 19394 deletions

View File

@@ -4,8 +4,16 @@ Browser extension that adds the "Opportunities Extension" shortcut to the permit
## Development Structure
- `src/content.js`: single source file maintained by the project.
- `src/content/`: page integration, modal shell, theme and content composition.
- `src/background/`: synchronous message registration and background composition.
- `src/features/`: opportunities, products, consumption, AI summaries, preferences and debug.
- `src/auth/`: credential-specific selection, reuse and session recovery.
- `src/services/`: Fusion/SPA operations and background HTTP/JSON transport.
- `src/platform/`: narrow browser messaging, cookies, tabs and storage adapters.
- `src/shared/messages.js`: names of the existing cross-context messages.
- `scripts/build.mjs`: generates browser-specific packages.
- `tests/`: isolated synthetic regression tests and a visual fixture.
- `docs/`: architecture/contracts and incremental refactoring record.
- `dist/chromium/`: output for Google Chrome and Microsoft Edge.
- `dist/firefox/`: output for Firefox.
@@ -19,6 +27,41 @@ npm run build
The build recreates `dist/chromium` and `dist/firefox` from the same source.
Do not edit files under `dist` directly. Both targets use Manifest V3, with a
Chromium service worker and Firefox background scripts. The build composes classic
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
context. It serializes `src/content/styles/redwood.css` at build time. There is no
runtime module loader, browser-side eval, ES-module requirement or new dependency.
## Tests and Maintenance
```bash
npm test
npm run check
```
Tests use built-in Node.js modules and synthetic credentials, without real Fusion
requests. The characterization suite verifies manifests, source/output equality,
fiscal dates, payloads, preferences, pagination, cache/progress and both browser API
styles. Architecture tests enforce source inclusion, explicit factory dependencies,
isolated state and unchanged CSS. These complement consumption, session recovery
and AI suites. `check` also fails if generated packages are stale.
There is no separate lint configuration or runtime dependency installation.
See [architecture and contracts](docs/architecture.md) and the
[incremental refactoring record](docs/refactoring.md) for execution boundaries,
authentication, storage, known risks and verification limits.
Mock-based tests do not replace testing the installed extension in each browser.
Without npm, run `node scripts/build.mjs`, `node scripts/test.cjs` and
`node scripts/build.mjs --check`. Development checks were run with Node 24.19.0.
For a local UI smoke test, run `node scripts/preview.mjs` and open the loopback URL
printed by the command. The fixture uses synthetic opportunities, tokens, products,
AI text and consumption; unexpected requests fail closed. It does not authenticate
against Fusion or read real extension storage. `/consumption-trend.html` provides
a focused chart fixture. Stop the server with Ctrl+C when finished.
## Release 1.0.0
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
@@ -37,7 +80,7 @@ The build recreates `dist/chromium` and `dist/firefox` from the same source.
## Loading in Development Mode
### Chrome ou Microsoft Edge
### Chrome or Microsoft Edge
1. Open `chrome://extensions` or `edge://extensions`.
2. Enable Developer mode.

File diff suppressed because it is too large Load Diff

7571
dist/chromium/content.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

7571
dist/firefox/content.js vendored

File diff suppressed because one or more lines are too long

198
docs/architecture.md Normal file
View File

@@ -0,0 +1,198 @@
# Architecture and Compatibility Contracts
This document describes the integrated modular implementation. Verification and
preexisting risks are tracked in [refactoring.md](refactoring.md).
## Sources, Outputs, and Runtime Boundaries
- Editable sources are factory modules under `src/`; the former monoliths were removed.
- `scripts/sources.cjs` explicitly lists modules for each context. `compose.cjs`
validates syntax, serializes CSS, wraps the modules in an IIFE and calls the
corresponding application factory/start method. `build.mjs` writes two packages.
- `dist/chromium` serves Chrome and Edge; `dist/firefox` serves Firefox. Do not edit outputs.
- Both manifests use V3. Chromium has a classic background service worker; Firefox
uses `background.scripts`. Neither entry point is declared as a module.
- Content scripts run at `document_idle`, in all matching frames on the two
configured Fusion welcome pages. Their code also checks the hostname/path.
- There are no extension UI pages, injected page-context scripts, custom CSP,
declared execution world, or web-accessible resources.
- Permissions remain `cookies` and `storage`, with host access limited to the
configured Fusion host and `spa.oracle.com`.
Content owns the DOM and per-frame state. Background owns privileged cookies,
temporary tabs, extension storage and SPA requests. These contexts communicate
using serializable runtime messages, not shared variables. Page localStorage is
different from extension storage and is not a safe place for credentials.
## Current Ownership and Side Effects
| Context | Responsibilities | State and effects |
| --- | --- | --- |
| Content | Fusion tile and main/preferences/consumption modals | DOM nodes, injected CSS, keyboard/pointer/resize listeners, MutationObserver |
| Content | Opportunity query and presentation | Filter sets, sorting, search, request versions, column formatters, dashboard animation |
| Content | Products UI | Current request ID, product map, expanded rows, progress, last update |
| Content | Consumption UI | Plan state, per-plan/period trend cache, chart navigation and selected series |
| Content | AI summary | Per-opportunity in-flight state and summary; fresh generation on expansion |
| Content | Authentication/debug | General and scoped token promises, SPA authorizations, request logs, visibility flag |
| Background | Message dispatcher | Synchronously registered listener, asynchronous replies using `return true` |
| Background | Products | Four workers, per-opportunity in-flight map, lazy cache read and serialized writes |
| Background | Cookie/session recovery | Inactive tabs, polling/retry delays, cleanup in `finally` |
| Background | SPA operations | HTTP/JSON parsing, account/plan extraction and debug envelopes |
Closing the main modal hides it rather than destroying it. Many requests can
continue while it is hidden. Version and request-ID checks suppress stale UI
updates; they do not abort network traffic. No AbortController is currently used.
Some listeners have window guards; there is no general disposal lifecycle.
## Authentication and API Routing
Never substitute one token family for another or assume an opaque token is a JWT.
Production tokens are held in memory, not deliberately persisted by the extension.
| Credential | Acquisition | Consumers | Lifetime/recovery |
| --- | --- | --- | --- |
| XSRF | Background cookie lookup, then content `document.cookie` fallback | `x-xsrf-token` for both tokenrelays | Cookie recovery tab on missing/invalid response; existing retry rules |
| Fusion general | GET `/fscmRestApi/tokenrelay` | Opportunity POST and product GET Bearer | Content promise/cache; no expiry-based refresh equivalent to Sales Common |
| Sales Common | GET `/fscmRestApi/tokenrelay?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F` | AI summary Bearer | In-memory `expires_in`, shared in-flight acquisition; at most one summary retry after 401/403 |
| ACCP-TS | SPA consumer service `uiaasHeader.value` | Account summary authorization | Retrieved with the consumer service response; expiry recorded, not automatically acted on |
| OCICONS-TS | SPA consumer service `uiaasHeader.value` | Account plans and consumption trend authorization | Same service response, distinct credential from ACCP-TS |
Fusion calls use `https://eeho.fa.us2.oraclecloud.com`; SPA calls use
`https://spa.oracle.com/oalcrm/web/api/`. Preserve `credentials: include` and
operation-specific headers rather than unifying all calls under a default token.
| Request | Method/path | Important contract |
| --- | --- | --- |
| requestOpportunities | POST `/crmRestApi/searchResources/11.13.18.05/custom-actions/queries` | RecordSet OR plus EffectiveDate AND, original fields, Preference transient, pages of 15 |
| requestOpptyProducts | GET `/crmRestApi/rest/rv:be91c002-2e5d-4ed2-a37e-e0c837bf141f/en/11.13.18.05:9/opportunities/{number}/child/ChildRevenue` | Encoded identifier, original query/fields/order, count/offset pagination, maximum 250 pages |
| requestGenerateAiSummary | POST `/api/sales-common/gen-ai/v1/summarizations/generate` | `{path,promptCode,object,puid}`; prompt `cx.sales.generate_opportunity_summary`; only summaryText displayed |
| requestConsumerTokenService | GET `g2m-consumer-application/consumerTokenService` | No-store; parse JSON; HTML 200 recovery via inactive SPA UI tab; maximum five HTTP attempts |
| requestAccountSummary | POST `provider-proxy/g2m-account/service/accountsummary` | ACCP-TS; `[{key:"regId",value:partyNumber}]` |
| requestAccountPlans | POST `provider-proxy/g2m-consumption/service/v1/account/accountPlans/{id}` | OCICONS-TS; original hierarchy/products/language/planStatus payload |
| requestConsumptionService{Period} | GET `provider-proxy/g2m-consumption/service/v1/plan/trend/{subPlanNum}?quarter=All&timePeriod={period}` | G2m-Authorization; quarterly/monthly/weekly/daily; weekly default |
SPA parsing removes BOM and the existing anti-JSON-hijacking prefixes before
JSON.parse. Content uses response.json directly. Do not silently unify these behaviors.
POST retry policies require operation-specific review; do not add generic retries.
## Message Contracts
All names below have the `opportunitiesExtension.` prefix. Unknown messages return
false without a response. Supported background messages return true immediately
and later call sendResponse. `platform/background-api.js` centralizes the existing
callback/promise differences. `platform/content-messages.js` retains the original
cookie callback path as well as general runtime messaging; no browser behavior
was silently changed during extraction.
| Message | Input | Result |
| --- | --- | --- |
| getXsrfToken | type | ok, cookieName, token, matchedCookieNames, lookupDetails; error on failure |
| refreshXsrfCookie | type; sender tab context | ok, refreshed, cookieName, durationMs, error |
| requestOpptyProducts | requestId, accessToken, optyNumbers, cacheMaxAgeMs | ok, requestId, completed, total, failed, cacheHits; error envelope on dispatcher failure |
| opptyProductsProgress | Background to sender tab/frame | requestId, state, counters; per-item optyNumber/items/fromCache/cachedAt or error |
| requestConsumerTokenService | type; sender tab context | ok, authorizations.accountSummary/accountPlans, debugRequests |
| requestAccountPlans | partyNumber, authorizations | ok, accountPlanId, plans, debugRequests |
| requestConsumptionTrend | subPlanNum, period, authorization | ok, subPlanNum, period, items, debugRequests |
SPA failures return `{ok:false,error,debugRequests}`. Progress states include
`running`, `complete` and `complete-with-errors`; content additionally tracks idle
and error states. A new content request ID rejects progress from an older job.
Do not expose a caller-provided arbitrary request URL through the background.
## Storage Contracts
| Location/key | Value |
| --- | --- |
| Page localStorage: `opportunities-extension-filter-preferences` | JSON object: period, stages, statuses, customerKeys, ownerKeys, customerSearch, ownerSearch, tableSearch; unset customer/owner selections may be null |
| Page localStorage: `opportunities-extension-theme` | `light` or `dark` |
| Page localStorage: `opportunities-extension-opportunity-type-preferences` | JSON object: opportunityTypeValues, opptyProductsCacheHours |
| Extension storage.local: `opportunitiesExtension.opptyProductsCache.v1` | Object indexed by OptyNumber, each entry `{fetchedAt,items}` |
Product TTL defaults to 24 hours and is capped at 30 days in background. Storage
failures are tolerated for the active view. Recreating background reloads stored
products but does not restore in-flight jobs. Cache TTL does not physically delete
expired entries. Account/container isolation limitations are tracked separately.
## UI Contracts
Preserve labels/IDs/classes, column order, URLs, clipboard behavior, sort/filter
semantics, formatting, theme colors, keyboard shortcuts and row expansion.
WON includes WON_PENDING without rewriting StatusCode. Owner uses first/last name
with the existing fallbacks. Changing reference resets customers/owners to all;
closing/reopening preserves search. Fiscal years begin in June; RENEWALS currently
has no date range and therefore does not load opportunities.
Preserve SVG chart data normalization, animation, tooltips and series selection.
AI text is inserted with textContent. Debug masking and DOM isolation have known
limitations; see the separate security backlog rather than assuming full redaction.
## Module Ownership and Dependencies
`content/main.js` and `background/main.js` only initialize. Each `application.js`
is a composition root: constructs feature-owned state and factories, wires explicit
function dependencies, then exposes `start`. The returned components are available
to isolated tests, not published on the page/global object in production.
Factories do not import or instantiate peers. Features receive service/auth ports;
services/auth receive transport/platform ports; pure models receive data. The
composition roots supply callback notifications for UI updates and debug events,
preserving existing interactions without module-import cycles. Shared messages
are the only source included in both contexts, with distinct instances/state.
Some stateful UI collaborators intentionally share a feature-owned state object;
these objects are created per application, never stored in a global registry.
| Original responsibility | Integrated module(s) | Reason for boundary |
| --- | --- | --- |
| content initialization/tile | `content/main.js`, `fusion-page.js` | Page lifecycle and route guard |
| modal/theme/CSS | `content/modal.js`, `theme.js`, `styles.js`, `styles/redwood.css` | DOM shell separate from operations and cascade |
| fiscal dates/query payload | `features/opportunities/fiscal-periods.js`, `services/fusion/opportunities.js` | Pure date rules vs HTTP operation |
| table/search/filters/formatting/dashboard | `features/opportunities/` | Related presentation responsibilities, explicit state |
| saved settings | `features/preferences/storage.js`, `view.js` | Serialization/fallbacks vs dialog |
| token families | `auth/content-session.js`, `sales-common.js`, `token-relay.js`, `spa-credentials.js` | Different credentials and lifetime policies |
| cookie lookup/recovery, SPA session | `auth/xsrf-*.js`, `consumer-session.js` | Privileged recovery separate from UI |
| product queue/cache | `features/products/background-jobs.js`, `cache.js`, `services/fusion/products.js` | Scheduling, persistence and pagination |
| product progress/details | `features/products/content-jobs.js`, `details.js` | Message consumer vs row rendering |
| AI summary | `features/ai-summary/content.js`, `state.js` | Per-opportunity lifecycle, safe text and auth port |
| consumption | `features/consumption/`, `services/spa/` | Plans, chart model/view/controller vs HTTP operations |
| debug | `features/debug/content-log.js`, `content-view.js` | Retention/formatting vs DOM presentation |
| background dispatcher | `background/messages.js`, `shared/messages.js` | Stable serializable message contracts |
| browser APIs | `platform/background-api.js`, `content-messages.js` | Only APIs actually used, not a universal wrapper |
The background composition deliberately has no DOM dependency. Listener registration
is synchronous, before any storage read or request. Worker restart reconstructs
volatile state and lazily reads the existing product cache; tokens are not persisted.
Content effects (DOM, fetch, storage, animation) enter through the environment at
the composition root. localStorage access stays lazy to retain blocked-storage
fallbacks. Tests may inject effects or individual callback ports without changing
source strings. Time-sensitive modules are tested with isolated clocks.
## Adding a Feature
1. Define its message/API/storage contract and synthetic expected behavior first.
2. Add a factory under the owning feature, or a service under the correct host.
Accept narrow function dependencies; do not call another factory inside it.
3. Add its source path to the appropriate array in `scripts/sources.cjs`. Source
inclusion and duplicate factory checks run automatically.
4. Construct it in that context's `application.js`, supplying auth, platform and
render callbacks explicitly. Extend `background/messages.js` only if crossing
contexts is necessary; do not expose arbitrary destinations.
5. Add a `tests/*.test.cjs` suite using `tests/support/application.cjs` for composed
integration or `vm.Script` for an individual pure factory. Use only synthetic
data and fail unexpected network requests.
6. Run tests, build and `--check`; inspect the local fixture and load both packages
in the target browsers. Record any browser not available for verification.
As a concrete example, adding a new consumption period should first define its
request/data/authorization contract and synthetic tests, then extend the period
allowlist and normalization/presentation through the existing trend message. It
must not add a URL-proxy message or reuse the general Fusion token. These edits
belong in the consumption feature and SPA service, not the bootstrap.
## Source vs Generated Code
Distribution `content.js` and `background.js` are generated bundles and may remain
large. No copy/export/execute JavaScript generator feature was found in the source.
AI summaries are text, not executable JavaScript. No empty generators subsystem
was added. Build serialization uses JSON.stringify for CSS, retains quotes,
backslashes, Unicode and line breaks, and is syntax-checked by vm.Script. Visual
fixtures now load composed scripts normally; they no longer eval rewritten sources.

63
docs/browser-smoke.md Normal file
View File

@@ -0,0 +1,63 @@
# Native Browser Validation
This checklist complements the synthetic tests; it is not evidence of a native
pass until executed and recorded separately for each browser.
## Preconditions
- Use an authorized test profile/account. Do not delete cookies, sign out, change
account security settings or inspect credential values to force failure cases.
- Connect Chrome/Edge through Settings > Computer use for assisted browser tests.
Firefox needs a separate supported/manual test session.
- Run `node scripts/build.mjs --check` and `node scripts/test.cjs` first.
- Load `dist/chromium` using Load unpacked in Chrome/Edge, or load
`dist/firefox/manifest.json` as a temporary Firefox add-on. Verify the source
directory and version 1.0.1; avoid testing a previously installed stale copy.
- Keep debug secrets hidden. Record only status codes, request names, durations
and sanitized errors. Do not export real tokens, response bodies or account data.
- AI generation and tests requiring a deliberately expired session must use an
explicitly authorized test environment. Do not trigger live writes solely to
complete this checklist.
## Checklist
| Check | Expected result |
| --- | --- |
| Package load | No manifest/background/content syntax errors; no additional permissions |
| Both Fusion welcome routes | Exactly one tile before the add-icon item; no tile on unrelated pages |
| Modal open/close/reopen | Same search value and matching results; Esc closes the correct dialog |
| Filters and sorting | Correct customer/owner/stage/status/text combinations; WON includes WON_PENDING |
| Reference change | Customer and owner reset to all; stale responses do not overwrite new results |
| Preferences | Save/reopen retains selected record sets and cache duration; restore original settings afterward |
| Products | Progress completes; badges/counts and expanded rows agree; warm cache avoids redundant product fetches |
| AI in authorized test environment | Summary appears without replacing products; concurrent expansions share scoped-token acquisition; reopen uses valid token |
| Consumption | Dialog opens promptly, weekly chart by default; plan/period changes and series filters work |
| Themes and layout | Light/dark controls work; check long names, WON_PENDING and full update timestamps |
| Debug | Expected operation names/statuses; sensitive values remain hidden |
| Background restart | Close its inspector, allow suspension, then trigger a message; listener responds and stored product cache is reloaded |
| Multiple tabs | Progress remains scoped to requesting frame/job; no duplicate UI/listeners from normal navigation |
| Firefox containers | Test only in authorized containers; record current cookie/cache isolation limitations without silently changing behavior |
| Natural session expiry | When encountered, record recovery outcome and temporary-tab cleanup without revealing cookies or forcing sign-out |
Background interruption does not currently resume in-flight jobs. Do not mistake
that known limitation for a new regression or claim a mocked restart proves native
worker suspension. Any differences must be compared with the pre-refactor behavior.
## Execution Record
2026-09-17 preflight:
| Target | Discovery | Native test result |
| --- | --- | --- |
| Chrome | Executable version 153.0.8010.37; automation not connected | Blocked pending connection |
| Edge | Executable version 153.0.4234.32; automation not connected | Blocked pending connection |
| Firefox | No executable found at the checked standard Program Files path; installation not confirmed | Not run |
The running-process diagnostic was denied by the environment, so browser running
state was not established. Connected targets were Brave and the in-app browser;
neither was substituted for this native matrix. The user chose to connect
Chrome/Edge. All five local suites and the output consistency check passed again.
No real Fusion/SPA calls, runtime changes, release or commit were made in preflight.
For each completed run, append date, browser/version, package path, checklist
outcomes, sanitized errors and remaining gaps. Never mark unexecuted checks passed.

184
docs/refactoring.md Normal file
View File

@@ -0,0 +1,184 @@
# Incremental Refactoring Record
## Stage 1: Characterization Baseline
Stage 1 initially added tests and documentation only, before any runtime extraction.
The following records that historical baseline. The completed integration and
remaining verification gaps are recorded under "Completed Extraction Stages" below.
### Coverage Added
`tests/characterization.test.cjs` contains 22 test cases using Node's built-in
test runner, assert and vm. `tests/support/characterization.cjs` provides isolated
fixtures: no real browser cookies, account identifiers, tokens or network access.
Unexpected fetches reject rather than falling through to global fetch.
- Exact current manifests and source/output equality, with classic-script syntax validation.
- Disallowed-origin initialization and reinjection when the tile already exists.
- Fiscal boundaries, leap years, rolling quarters and unsupported RENEWALS.
- Full opportunity query payload, pagination offsets/counts and stale request versions.
- Filter storage round-trip, WON_PENDING, search, numeric sort, reference reset and malformed preferences.
- Missing-cookie tokenrelay recovery and existing 400/401/403 retry limit.
- Chrome callback and Firefox promise mocks for background dispatch, storage, cookies and tabs.
- Product pagination, default/custom TTL, concurrency limit, in-flight sharing and partial errors.
- Persisted product reads after recreating the background context; storage failures and stalled pages.
- XSRF discovery, recovery timeout, successful recovery and temporary-tab cleanup.
- SPA credential selection, account payloads, encoded identifiers and debug envelopes.
- Invalid messages, network errors, HTTP 401 and HTTP 200 invalid JSON in consumption.
Existing suites remain intact: consumption normalization/requests, consumer-session
recovery, AI summary rendering/state, scoped token reuse/expiry and bounded retry.
Tests for the current security defects are deliberately not encoded as acceptable
behavior. They belong to separately approved fixes.
### Former Temporary Test Seam
The original content IIFE had no exports, so baseline tests initially exposed
functions before initialization with a guarded source marker. That temporary
mechanism is now removed. Tests use the actual application factories through
`tests/support/application.cjs`, without replacing source strings. Background
integration tests still execute the production listener and asynchronous replies.
### Running Checks
```sh
npm test
npm run check
```
Without npm, use the Node commands in package.json individually. The new suite is:
```sh
node tests/characterization.test.cjs
```
At baseline, `build.mjs --check` did not compare output contents. It now checks
both generated manifests and both scripts byte-for-byte against composition.
Characterization tests retain the exact manifest expectations.
Stage-1 verification on Node.js v24.19.0:
| Command | Result |
| --- | --- |
| `node --check src/content.js` and `node --check src/background.js` | Passed |
| `node --check tests/support/characterization.cjs` | Passed |
| `node tests/consumptionTrend.test.cjs` | Passed |
| `node tests/consumerTokenSessionRetry.test.cjs` | Passed |
| `node tests/aiSummary.test.cjs` | Passed |
| `node tests/characterization.test.cjs` | 22 passed, 0 failed |
| `node scripts/build.mjs --check` | Passed; distribution equivalence also checked by the suite |
| `git diff --check` | Passed |
The individual commands from the npm scripts were executed directly. No build
regeneration was needed because runtime sources and the build were unchanged;
`git diff -- src dist scripts/build.mjs` was empty.
### Verification Limits
- API mocks are not proof of Chrome/Edge/Firefox compatibility or real authentication.
- Recreating a VM tests cache rehydration, not actual service-worker suspension or job recovery.
- DOM mocks do not prove layout, focus, accessibility or SPA lifecycle correctness.
- The original visual fixture used eval. The completed extraction removed it and
serves the composed factory through a normal local script instead.
- No installed-browser smoke test or live Fusion request was performed in stage 1.
- No JavaScript export/generator feature was located. Do not create a generators
directory until its actual source and standalone-output contracts are identified.
## Separate Security and Reliability Backlog
These are preexisting observations, not authorized functional changes in this refactor.
| Issue | Evidence/impact | Separate change to evaluate |
| --- | --- | --- |
| Debug redaction after truncation | Invalid/truncated JSON is returned unmasked by formatDebugBody; reproduced with a synthetic token | Redact before truncation, fail closed on malformed bodies, test all response shapes |
| Debug secrets and shared DOM | Raw headers/bodies are retained for the reveal control; revealed text is in page DOM | Review retention, safe diagnostic schema and whether revealing credentials remains acceptable |
| Cookie store selection | Lookup scans all stores instead of binding to sender account/container | Scope selection to the requesting context and test multi-account behavior |
| Product cache isolation | Cache and in-flight keys use OptyNumber only | Design identity partitioning and explicit cache migration/retention policy |
| Privileged message validation | No explicit sender identity/origin check; supplied header names are accepted | Validate senders/messages and allowlist headers without introducing an arbitrary proxy |
| Session tab retry | Opening failures retry indefinitely; interruption can strand work | Decide cancellation/limits and worker lifecycle recovery explicitly |
| DOM lifecycle | Observer can remain active; guards do not cover every reinjection/resize path | Define ownership/disposal and characterize navigation before changing behavior |
| Token expiry | General/SPA tokens have policies different from Sales Common | Review independently; do not unify policies as part of extraction |
Never include real secrets in repro cases, snapshots, documentation or test output.
Do not add persistent token storage to compensate for worker termination.
## Completed Extraction Stages
All modules listed below are integrated through `scripts/sources.cjs` and the
two composition roots. Former `src/content.js` and `src/background.js` were removed;
their names remain only for the generated distribution scripts. No version change,
commit, publication, runtime dependency, permission expansion or CSP change occurred.
| Stage/responsibility | Files/boundaries | Preserved contracts and evidence | Remaining risk/next safe step |
| --- | --- | --- | --- |
| Build and pure logic | `scripts/compose.cjs`, `sources.cjs`; fiscal-periods, formatters, consumption model | Classic IIFEs, exact manifests, dates, payload/normalization tests; syntax checked per source and composed bundle | Native browser package loading still needs a smoke test |
| Platform and dispatch | `platform/`, `background/main.js`, `messages.js`, `shared/messages.js` | Sync listener registration, callback/promise mocks, async responses, sender-frame progress and unknown-message behavior | Sender validation is a separate security change |
| Auth and HTTP operations | `auth/`, `services/` | Credential selection, scoped-token reuse/expiry, 401/403 retry, missing-cookie and HTML-session recovery, request/debug envelopes | Real SSO and worker suspension not simulated completely |
| Products | `features/products/` | Four-worker queue, pagination, TTL, cache restore, in-flight sharing, partial errors and expansion behavior | Existing identity/cache-retention policy unchanged |
| Content features | opportunities, preferences, AI-summary, consumption and debug factories/state | Filter/search storage round-trip, sorting, stale requests, AI safe text, plan/period controls, preserved labels and DOM | Existing DOM lifecycle and narrow-screen limitations remain |
| Bootstrap, CSS and documentation | `content/main.js`, `background/main.js`, application roots, styles; README/docs | CSS byte-identical to original (hash test); factories connected, no orphan sources or peer-factory dependencies; old marker/eval fixture removed | Native browser matrix below remains pending |
The automated suites were run after the initial extraction and again after the
platform/auth refinements, cleanup and final composition. Intermediate verification
found and corrected test-harness expectations for source inventory and fixture
callback messaging; no production auth, cookie or session policy was changed.
### Final Automated Verification
Executed with Node.js 24.19.0, directly through the package-script equivalents:
| Command | Result |
| --- | --- |
| `node scripts/build.mjs` | Both packages regenerated; all source/composed JavaScript syntax valid |
| `node scripts/test.cjs` | All five suite files passed: AI, architecture (6 cases), characterization (22 cases), session recovery, consumption |
| `node scripts/build.mjs --check` | Generated scripts/manifests match source composition |
| `git diff --check` | No whitespace errors |
There is no configured linter. Syntax validation uses Node's JavaScript parser
through vm.Script, not eval or new Function in the extension. No generators feature
was located; standalone JavaScript generation therefore has no feature-specific
snapshot to assert. The architectural suite verifies deterministic composition,
source coverage, dependency injection, independent per-frame state and lazy
localStorage failure handling. Feature suites use synthetic credentials only.
### UI Smoke Verification
The loopback fixture was exercised in the Codex in-app browser, using the same
composed content factories with injected synthetic runtime/fetch/storage adapters.
It is not an installed extension and does not validate privileged browser APIs.
- Paginated synthetic opportunities, product progress/counts and dashboard rendered.
- Search text/filter remained after closing and reopening the modal.
- Light/dark toggle and product expansion retained the existing presentation.
- AI text, including literal HTML delimiters, appeared as text above the product table.
- Preferences retained checkbox/cache-duration selections after Save and reopen.
- Cloud Consumption opened with plan tabs; weekly default, daily switch, alternate
plan and series visibility toggles worked. Debug listed scoped relay and AI calls.
- Screenshots inspected at 1920x1080, 1280x720 and 390x844. Desktop columns rendered
with complete Last Update Date and WON_PENDING. Existing compact-table scrolling
and clipping at 1280 remain unchanged; mobile controls stack. This is not a full
mobile workflow/accessibility audit and no layout redesign was included.
- No console errors were reported during the exercised flows.
### Outstanding Native Browser Matrix
| Target | Package/runtime | Status |
| --- | --- | --- |
| Chrome | `dist/chromium`, MV3 service worker | Not run as an installed extension |
| Edge | `dist/chromium`, MV3 service worker | Not run as an installed extension |
| Firefox | `dist/firefox`, MV3 background scripts | Not run as an installed extension |
The available automation connection exposed the in-app browser and a Brave
profile, not Chrome, Edge or Firefox. No live Fusion requests, user-session access
or extension installation were used to substitute for the missing native tests.
Actual suspension/reactivation, container routing and SSO remain unverified.
The next safe validation is to load the generated packages in an authorized test
profile in each target browser, then exercise tile insertion, modal reopen,
filters/preferences, products, AI, consumption, debug and background restart.
Resolve the separately listed security issues only in explicitly approved changes.
See [the native browser checklist and execution record](browser-smoke.md) for the
2026-09-17 follow-up. Local tests and output checks passed again; Chrome/Edge were
installed but not connected to automation. Native verification remains pending
while the user connects those browsers.

View File

@@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"build": "node scripts/build.mjs",
"check": "node --check src/content.js && node --check src/background.js && node tests/consumptionTrend.test.cjs && node tests/consumerTokenSessionRetry.test.cjs && node tests/aiSummary.test.cjs && node scripts/build.mjs --check",
"test": "node tests/consumptionTrend.test.cjs && node tests/consumerTokenSessionRetry.test.cjs && node tests/aiSummary.test.cjs"
"check": "node scripts/build.mjs --check && node scripts/test.cjs",
"test": "node scripts/test.cjs",
"preview": "node scripts/preview.mjs"
}
}

View File

@@ -1,12 +1,12 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import composition from "./compose.cjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const srcDir = path.join(rootDir, "src");
const distDir = path.join(rootDir, "dist");
const contentScript = await readFile(path.join(srcDir, "content.js"), "utf8");
const backgroundScript = await readFile(path.join(srcDir, "background.js"), "utf8");
const contentScript = composition.composeScript("content");
const backgroundScript = composition.composeScript("background");
const checkOnly = process.argv.includes("--check");
const baseManifest = {
@@ -70,6 +70,11 @@ for (const target of targets) {
JSON.parse(manifestJson);
if (checkOnly) {
const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript };
for (const [file, contents] of Object.entries(expected)) {
const actual = await readFile(path.join(distDir, target.name, file), "utf8");
if (actual !== contents) throw new Error(`Stale output: dist/${target.name}/${file}. Run npm run build.`);
}
continue;
}

29
scripts/compose.cjs Normal file
View File

@@ -0,0 +1,29 @@
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const sources = require("./sources.cjs");
const root = path.resolve(__dirname, "..");
/** Compose classic scripts at build time; never load or evaluate code at runtime. */
function composeScript(target, { bootstrap = true } = {}) {
const files = sources[target];
if (!files) throw new Error(`Unknown script target: ${target}`);
if (new Set(files).size !== files.length) throw new Error(`Duplicate source in ${target}`);
const parts = files.map(file => {
const source = fs.readFileSync(path.join(root, file), "utf8");
new vm.Script(source, { filename: file });
return `// Source: ${file}\n${source}`;
});
if (target === "content") {
const css = fs.readFileSync(path.join(root, "src/content/styles/redwood.css"), "utf8");
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
}
const factory = target === "content" ? "createContentApplication" : "createBackgroundApplication";
const ending = bootstrap ? `${factory}().start();` : `return ${factory};`;
const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`;
new vm.Script(output, { filename: `${target}.js` });
return output;
}
module.exports = { composeScript, sources };

38
scripts/preview.mjs Normal file
View File

@@ -0,0 +1,38 @@
import http from "node:http";
import { readFile } from "node:fs/promises";
import composition from "./compose.cjs";
// Development-only routes. Never expose the workspace or forward API requests.
const routes = new Map([
["/", ["../tests/fixtures/extension.html", "text/html"]],
["/extension.js", ["../tests/fixtures/extension.js", "text/javascript"]],
["/consumption-trend.html", ["../tests/fixtures/consumption-trend.html", "text/html"]]
]);
const server = http.createServer(async (request, response) => {
const pathname = new URL(request.url, "http://127.0.0.1").pathname;
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
if (request.method !== "GET") {
response.writeHead(405).end();
return;
}
try {
if (pathname === "/fixture-runtime.js") {
response.setHeader("Content-Type", "text/javascript");
response.end(`window.createFixtureApplication = ${composition.composeScript("content", { bootstrap: false })}`);
return;
}
const route = routes.get(pathname);
if (!route) {
response.writeHead(404).end();
return;
}
response.setHeader("Content-Type", `${route[1]}; charset=utf-8`);
response.end(await readFile(new URL(route[0], import.meta.url)));
} catch {
response.writeHead(500).end("Fixture could not be loaded.");
}
});
server.listen(Number(process.env.PORT) || 0, "127.0.0.1", () => {
console.log(`Synthetic extension preview: http://127.0.0.1:${server.address().port}/`);
});

72
scripts/sources.cjs Normal file
View File

@@ -0,0 +1,72 @@
module.exports = {
"content": [
"src/shared/messages.js",
"src/content/contracts.js",
"src/auth/session-state.js",
"src/auth/sales-common-state.js",
"src/features/opportunities/query-state.js",
"src/features/opportunities/table-state.js",
"src/features/opportunities/filter-state.js",
"src/features/preferences/state.js",
"src/features/products/content-state.js",
"src/features/ai-summary/state.js",
"src/features/consumption/plans-state.js",
"src/features/consumption/trend-state.js",
"src/features/opportunities/dashboard-state.js",
"src/features/debug/state.js",
"src/features/opportunities/columns.js",
"src/content/fusion-page.js",
"src/content/modal.js",
"src/content/theme.js",
"src/content/icons.js",
"src/content/svg.js",
"src/content/styles.js",
"src/auth/content-session.js",
"src/auth/sales-common.js",
"src/auth/token-relay.js",
"src/auth/xsrf-content.js",
"src/platform/content-messages.js",
"src/features/opportunities/controller.js",
"src/services/fusion/opportunities.js",
"src/features/opportunities/fiscal-periods.js",
"src/features/opportunities/table.js",
"src/features/opportunities/dashboard.js",
"src/features/opportunities/filters.js",
"src/features/opportunities/formatters.js",
"src/features/opportunities/links.js",
"src/features/preferences/storage.js",
"src/features/preferences/view.js",
"src/features/products/content-jobs.js",
"src/features/products/details.js",
"src/features/ai-summary/content.js",
"src/features/consumption/plans-action.js",
"src/features/consumption/plans-view.js",
"src/features/consumption/trend-controller.js",
"src/features/consumption/chart.js",
"src/features/consumption/model.js",
"src/features/debug/content-log.js",
"src/features/debug/content-view.js",
"src/content/main.js",
"src/content/application.js"
],
"background": [
"src/shared/messages.js",
"src/background/contracts.js",
"src/features/products/cache-state.js",
"src/auth/consumer-session.js",
"src/auth/spa-credentials.js",
"src/services/spa/accounts.js",
"src/services/spa/consumption.js",
"src/services/spa/response-shapes.js",
"src/services/http/background-json.js",
"src/features/products/background-jobs.js",
"src/features/products/cache.js",
"src/services/fusion/products.js",
"src/auth/xsrf-recovery.js",
"src/auth/xsrf-cookies.js",
"src/platform/background-api.js",
"src/background/main.js",
"src/background/messages.js",
"src/background/application.js"
]
};

13
scripts/test.cjs Normal file
View File

@@ -0,0 +1,13 @@
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const root = path.resolve(__dirname, "..");
const files = fs.readdirSync(path.join(root, "tests")).filter(name => name.endsWith(".test.cjs")).sort();
let failed = false;
for (const file of files) {
const result = spawnSync(process.execPath, [path.join(root, "tests", file)], { cwd: root, stdio: "inherit" });
if (result.error) console.error(result.error.message);
if (result.status !== 0) failed = true;
}
process.exitCode = failed ? 1 : 0;

View File

@@ -0,0 +1,222 @@
function createAuthConsumerSession({
CUSTOMER_TOKEN_MAX_ATTEMPTS,
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS,
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS,
CUSTOMER_TOKEN_SERVICE_URL,
CUSTOMER_TOKEN_SESSION_URL,
extractTokenServiceAuthorization,
fetchJson,
supportsFirefoxCookieStoreTabs,
tabsCreate,
tabsRemove,
wait
}) {
async function requestConsumerTokenService(sender) {
const debugRequests = [];
try {
const tokenResponse = await fetchConsumerTokenServiceUntilJson(sender, 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 fetchConsumerTokenServiceUntilJson(sender, debugRequests) {
let attempt = 0;
let sessionTab = null;
let receivedValidJson = false;
try {
while (attempt < CUSTOMER_TOKEN_MAX_ATTEMPTS) {
attempt += 1;
try {
const tokenResponse = await fetchJson(CUSTOMER_TOKEN_SERVICE_URL, {
method: "GET",
credentials: "include",
cache: "no-store",
headers: {
Accept: "application/json",
"Cache-Control": "no-cache, no-store, max-age=0",
Pragma: "no-cache"
}
}, "requestConsumerTokenService", debugRequests);
const debugEntry = debugRequests[debugRequests.length - 1];
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
}
receivedValidJson = true;
return tokenResponse;
} catch (error) {
const isHtmlFallback = error
&& error.code === "INVALID_JSON_RESPONSE"
&& error.httpStatus === 200;
const debugEntry = debugRequests[debugRequests.length - 1];
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
if (isHtmlFallback) {
debugEntry.metadata.retryLimitReached = "true";
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
error.message = debugEntry.error;
}
}
throw error;
}
if (!sessionTab) {
try {
sessionTab = await openConsumerTokenSessionTabWithRetry(sender, debugEntry);
if (debugEntry) {
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab";
}
} catch (sessionError) {
if (debugEntry) {
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
debugEntry.metadata.sessionBootstrapState = `tab-open-failed: ${sessionError.message || "unknown error"}`;
}
}
} else if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = "waiting-for-valid-session";
}
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON";
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
}
await wait(retryDelayMs);
}
}
throw new Error("requestConsumerTokenService retry loop ended unexpectedly.");
} finally {
if (sessionTab && Number.isInteger(sessionTab.id)) {
const debugEntry = debugRequests[debugRequests.length - 1];
try {
await tabsRemove(sessionTab.id);
if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json"
: "closed-after-retry-finished";
}
} catch (error) {
if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = `tab-close-failed: ${error.message || "unknown error"}`;
}
}
}
}
}
function getConsumerTokenRetryDelay(attempt) {
return Math.min(
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS * (2 ** Math.max(attempt - 1, 0)),
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS
);
}
async function openConsumerTokenSessionTabWithRetry(sender, debugEntry) {
let attempt = 0;
// Opening failures must not consume the token service's five attempts.
while (true) {
attempt += 1;
if (debugEntry) {
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
debugEntry.metadata.sessionTabOpenAttempt = String(attempt);
}
try {
const tab = await openConsumerTokenSessionTab(sender);
if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab";
delete debugEntry.metadata.sessionTabNextRetryDelayMs;
}
return tab;
} catch (error) {
const retryDelayMs = getConsumerTokenRetryDelay(Math.min(attempt, 5));
if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = "retrying-tab-open";
debugEntry.metadata.sessionTabLastOpenError = error.message || "unknown error";
debugEntry.metadata.sessionTabNextRetryDelayMs = String(retryDelayMs);
}
await wait(retryDelayMs);
}
}
}
function openConsumerTokenSessionTab(sender) {
const details = {
url: CUSTOMER_TOKEN_SESSION_URL,
active: false
};
if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) {
details.windowId = sender.tab.windowId;
}
if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) {
details.cookieStoreId = sender.tab.cookieStoreId;
}
return tabsCreate(details);
}
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab };
}

View File

@@ -0,0 +1,57 @@
function createAuthContentSession({
messageTypes,
AUTH_STATUS,
appendBackgroundRequestLog,
authSessionState,
requestTokenRelay,
sendRuntimeMessage,
updateAuthBadge
}) {
function requestTokenRelayOnce() {
if (!authSessionState.tokenRelayRequest) {
authSessionState.authStatus = AUTH_STATUS.pending;
updateAuthBadge(authSessionState.authStatus);
authSessionState.tokenRelayRequest = requestTokenRelay()
.then((token) => {
authSessionState.accessToken = token;
authSessionState.authStatus = AUTH_STATUS.authenticated;
requestConsumerTokenServiceOnce().catch(() => {});
return token;
})
.catch((error) => {
authSessionState.accessToken = "";
authSessionState.authStatus = AUTH_STATUS.unauthenticated;
throw error;
})
.finally(() => {
updateAuthBadge(authSessionState.authStatus);
});
} else {
updateAuthBadge(authSessionState.authStatus);
}
return authSessionState.tokenRelayRequest;
}
function requestConsumerTokenServiceOnce() {
if (!authSessionState.consumerTokenServiceRequest) {
authSessionState.consumerTokenServiceRequest = sendRuntimeMessage({
type: messageTypes.requestConsumerTokenService
}).then((response) => {
appendBackgroundRequestLog(response && response.debugRequests);
if (!response || !response.ok) {
throw new Error(response && response.error
? response.error
: "Unable to load consumer token service.");
}
return response.authorizations;
});
}
return authSessionState.consumerTokenServiceRequest;
}
return { requestTokenRelayOnce, requestConsumerTokenServiceOnce };
}

View File

@@ -0,0 +1,7 @@
/** @returns {{salesCommonToken: null | {access_token: string, expiresAt: number}, salesCommonTokenRequest: null | Promise<string>}} */
function createAuthSalesCommonState() {
return {
salesCommonToken: null,
salesCommonTokenRequest: null
};
}

33
src/auth/sales-common.js Normal file
View File

@@ -0,0 +1,33 @@
function createAuthSalesCommon({
TOKEN_RELAY_URL,
authSalesCommonState,
requestTokenRelay
}) {
function requestTokenRelaySalesCommon() {
if (authSalesCommonState.salesCommonToken && Date.now() < authSalesCommonState.salesCommonToken.expiresAt) {
return Promise.resolve(authSalesCommonState.salesCommonToken.access_token);
}
if (authSalesCommonState.salesCommonTokenRequest) {
return authSalesCommonState.salesCommonTokenRequest;
}
authSalesCommonState.salesCommonTokenRequest = requestTokenRelay({
name: "requestTokenRelaySalesCommon",
url: `${TOKEN_RELAY_URL}?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F`,
includeExpiry: true
}).then((token) => {
authSalesCommonState.salesCommonToken = token;
return token.access_token;
}).finally(() => {
authSalesCommonState.salesCommonTokenRequest = null;
});
return authSalesCommonState.salesCommonTokenRequest;
}
function invalidateSalesCommonToken(rejectedToken) {
if (authSalesCommonState.salesCommonToken && authSalesCommonState.salesCommonToken.access_token === rejectedToken) {
authSalesCommonState.salesCommonToken = null;
}
}
return { requestTokenRelaySalesCommon, invalidateSalesCommonToken };
}

View File

@@ -0,0 +1,8 @@
function createAuthSessionState({ AUTH_STATUS } = {}) {
return {
tokenRelayRequest: null,
consumerTokenServiceRequest: null,
accessToken: "",
authStatus: AUTH_STATUS.idle
};
}

View File

@@ -0,0 +1,40 @@
function createAuthSpaCredentials({
findNestedObject
}) {
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)
: ""
};
}
return { normalizeMessageAuthorization, extractTokenServiceAuthorization };
}

153
src/auth/token-relay.js Normal file
View File

@@ -0,0 +1,153 @@
function createAuthTokenRelay({
TOKEN_RELAY_URL,
createDebugBody,
getXsrfToken,
logRequestFailure,
logRequestStart,
logRequestSuccess,
maskToken,
readResponseBodyForDebug,
refreshXsrfCookie,
renderDebugPanel,
environment
}) {
const { fetch } = environment;
async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) {
let refreshResult = null;
let refreshAttempted = false;
while (true) {
const xsrfTokenSource = await getXsrfToken();
if (!xsrfTokenSource.token) {
if (refreshAttempted) {
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
throw new Error("XSRF token cookie not found after automatic refresh.");
}
refreshResult = await refreshXsrfCookie("missing-cookie");
refreshAttempted = true;
continue;
}
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
try {
const response = await fetch(url, {
method: "GET",
credentials: "include",
headers: {
"x-xsrf-token": xsrfTokenSource.token
}
});
const responseBody = await readResponseBodyForDebug(response);
if (!response.ok) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted && isInvalidXsrfStatus(response.status)) {
refreshResult = await refreshXsrfCookie(`tokenrelay-${response.status}`);
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw new Error(`Token relay failed with status ${response.status}.`);
}
let responseData;
try {
responseData = await response.json();
} catch (error) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted) {
refreshResult = await refreshXsrfCookie("tokenrelay-invalid-response");
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw error;
}
if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted) {
refreshResult = await refreshXsrfCookie("tokenrelay-missing-access-token");
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw new Error("Token relay response does not contain access_token.");
}
logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({
...responseData,
access_token: maskToken(responseData.access_token)
}, null, 2)));
if (includeExpiry) {
const lifetime = Number(responseData.expires_in);
return {
access_token: responseData.access_token,
expiresAt: Number.isFinite(lifetime) && lifetime > 0
? requestEntry.startedAt.getTime() + lifetime * 1000 : 0
};
}
return responseData.access_token;
} catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed.");
throw error;
}
}
}
function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name = "requestTokenRelay", url = TOKEN_RELAY_URL) {
return logRequestStart({
name,
method: "GET",
url,
headers: {
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
},
metadata: {
xsrfCookieName: xsrfTokenSource.cookieName || "(missing)",
xsrfTokenSource: xsrfTokenSource.source,
xsrfMatchedCookies: xsrfTokenSource.matchedCookieNames && xsrfTokenSource.matchedCookieNames.length
? xsrfTokenSource.matchedCookieNames.join(", ")
: "(none)",
xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length
? xsrfTokenSource.lookupDetails.join(" | ")
: "(none)",
xsrfTokenError: xsrfTokenSource.error || "",
xsrfRefresh: formatXsrfRefreshResult(refreshResult)
}
});
}
function isInvalidXsrfStatus(status) {
return status === 400 || status === 401 || status === 403;
}
function formatXsrfRefreshResult(result) {
if (!result) {
return "not-requested";
}
const outcome = result.refreshed ? "refreshed" : "not-refreshed";
const cookieName = result.cookieName || "(none)";
const duration = Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : "unknown";
return `${result.reason}; ${outcome}; cookie=${cookieName}; duration=${duration}; ${result.error || ""}`.trim();
}
return { requestTokenRelay, createTokenRelayRequestEntry, isInvalidXsrfStatus, formatXsrfRefreshResult };
}

42
src/auth/xsrf-content.js Normal file
View File

@@ -0,0 +1,42 @@
function createAuthXsrfContent({
getXsrfTokenFromExtensionCookies,
environment
}) {
const { document } = environment;
async function getXsrfToken() {
const extensionCookie = await getXsrfTokenFromExtensionCookies();
if (extensionCookie.token) {
return extensionCookie;
}
const xsrfCookie = document.cookie
.split(";")
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith("XSRF-TOKEN-"));
if (!xsrfCookie) {
return {
token: "",
cookieName: "",
source: extensionCookie.error ? "document.cookie fallback after cookies API error" : "document.cookie fallback",
matchedCookieNames: extensionCookie.matchedCookieNames || [],
lookupDetails: extensionCookie.lookupDetails || [],
error: extensionCookie.error || ""
};
}
const cookieName = xsrfCookie.slice(0, xsrfCookie.indexOf("="));
const tokenValue = xsrfCookie.slice(xsrfCookie.indexOf("=") + 1);
return {
token: decodeURIComponent(tokenValue),
cookieName,
source: "document.cookie",
matchedCookieNames: extensionCookie.matchedCookieNames || [],
lookupDetails: extensionCookie.lookupDetails || [],
error: ""
};
}
return { getXsrfToken };
}

116
src/auth/xsrf-cookies.js Normal file
View File

@@ -0,0 +1,116 @@
function createAuthXsrfCookies({
ORACLE_COOKIE_URLS,
ORACLE_DOMAIN,
XSRF_COOKIE_NAME,
XSRF_COOKIE_PREFIX,
cookiesGet,
cookiesGetAll,
cookiesGetAllCookieStores,
removeUndefinedValues
}) {
async function getXsrfTokenCookie() {
const lookupDetails = [];
const allCookies = [];
const stores = await getCookieStores(lookupDetails);
for (const store of stores) {
const exactCookie = await getExactCookieFromUrls(store.id, lookupDetails);
if (exactCookie) {
allCookies.push(exactCookie);
}
}
for (const store of stores) {
await collectCookies({
name: XSRF_COOKIE_NAME,
storeId: store.id
}, allCookies, lookupDetails);
}
for (const store of stores) {
await collectCookies({
domain: ORACLE_DOMAIN,
storeId: store.id
}, allCookies, lookupDetails);
await collectCookies({
domain: `.${ORACLE_DOMAIN}`,
storeId: store.id
}, allCookies, lookupDetails);
}
for (const store of stores) {
for (const url of ORACLE_COOKIE_URLS) {
await collectCookies({
url,
storeId: store.id
}, allCookies, lookupDetails);
}
}
const uniqueCookies = dedupeCookies(allCookies);
const xsrfCookies = uniqueCookies.filter((cookie) => cookie.name.startsWith(XSRF_COOKIE_PREFIX));
const exactCookie = xsrfCookies.find((cookie) => cookie.name === XSRF_COOKIE_NAME);
const hostCookie = xsrfCookies.find((cookie) => cookie.domain === ORACLE_DOMAIN || cookie.domain === `.${ORACLE_DOMAIN}`);
return {
cookie: exactCookie || hostCookie || xsrfCookies[0] || null,
matchedCookieNames: xsrfCookies.map((cookie) => `${cookie.name} (${cookie.domain}${cookie.path})`),
lookupDetails
};
}
async function getExactCookieFromUrls(storeId, lookupDetails) {
for (const url of ORACLE_COOKIE_URLS) {
const cookie = await cookiesGet({
url,
name: XSRF_COOKIE_NAME,
storeId
});
lookupDetails.push(`${JSON.stringify({ url, name: XSRF_COOKIE_NAME, storeId })} => ${cookie ? "found" : "not found"}`);
if (cookie) {
return cookie;
}
}
return null;
}
async function getCookieStores(lookupDetails) {
try {
const stores = await cookiesGetAllCookieStores();
lookupDetails.push(`getAllCookieStores => ${stores.length} store(s)`);
return stores.length ? stores : [{ id: undefined }];
} catch (error) {
lookupDetails.push(`getAllCookieStores => unavailable (${error.message || "unknown error"})`);
return [{ id: undefined }];
}
}
async function collectCookies(details, target, lookupDetails) {
const cleanDetails = removeUndefinedValues(details);
const cookies = await cookiesGetAll(cleanDetails);
target.push(...cookies);
lookupDetails.push(`${JSON.stringify(cleanDetails)} => ${cookies.length} cookie(s)`);
}
function dedupeCookies(cookies) {
const seen = new Set();
return cookies.filter((cookie) => {
const key = `${cookie.name}|${cookie.domain}|${cookie.path}|${cookie.storeId || ""}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
return { getXsrfTokenCookie, getExactCookieFromUrls, getCookieStores, collectCookies, dedupeCookies };
}

84
src/auth/xsrf-recovery.js Normal file
View File

@@ -0,0 +1,84 @@
function createAuthXsrfRecovery({
COOKIE_REFRESH_POLL_MS,
COOKIE_REFRESH_TIMEOUT_MS,
OPPORTUNITIES_LIST_URL,
getXsrfTokenCookie,
supportsFirefoxCookieStoreTabs,
tabsCreate,
tabsRemove,
wait
}) {
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;
}
return { refreshXsrfCookie, waitForXsrfCookieChange, hasCookieChanged };
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
function createBackgroundApplication(overrides = {}) {
const environment = {
fetch: globalThis.fetch,
browser: globalThis.browser,
chrome: globalThis.chrome,
setTimeout: globalThis.setTimeout,
...overrides
};
const messageTypes = createMessageTypes();
const contracts = createBackgroundContracts();
const productsCacheState = createProductsCacheState();
const authConsumerSession = createAuthConsumerSession({
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS,
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS,
CUSTOMER_TOKEN_SERVICE_URL: contracts.CUSTOMER_TOKEN_SERVICE_URL,
CUSTOMER_TOKEN_SESSION_URL: contracts.CUSTOMER_TOKEN_SESSION_URL,
extractTokenServiceAuthorization: (...args) => authSpaCredentials.extractTokenServiceAuthorization(...args),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
wait: (...args) => platformBackgroundApi.wait(...args)
});
const authSpaCredentials = createAuthSpaCredentials({
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args)
});
const spaAccounts = createSpaAccounts({
ACCOUNT_PLANS_PAYLOAD: contracts.ACCOUNT_PLANS_PAYLOAD,
ACCOUNT_PLANS_URL: contracts.ACCOUNT_PLANS_URL,
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
extractAccountPlans: (...args) => spaResponseShapes.extractAccountPlans(...args),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaConsumption = createSpaConsumption({
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaResponseShapes = createSpaResponseShapes();
const httpBackgroundJson = createHttpBackgroundJson({
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
environment
});
const productsBackgroundJobs = createProductsBackgroundJobs({
messageTypes,
OPPTY_PRODUCTS_CONCURRENCY: contracts.OPPTY_PRODUCTS_CONCURRENCY,
getOpptyProducts: (...args) => productsCache.getOpptyProducts(...args),
normalizeCacheMaxAge: (...args) => productsCache.normalizeCacheMaxAge(...args),
tabsSendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args)
});
const productsCache = createProductsCache({
OPPTY_PRODUCTS_CACHE_KEY: contracts.OPPTY_PRODUCTS_CACHE_KEY,
fetchAllOpptyProducts: (...args) => fusionProducts.fetchAllOpptyProducts(...args),
productsCacheState,
storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args),
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
});
const fusionProducts = createFusionProducts({
OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS,
OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT,
ORACLE_DOMAIN: contracts.ORACLE_DOMAIN,
environment
});
const authXsrfRecovery = createAuthXsrfRecovery({
COOKIE_REFRESH_POLL_MS: contracts.COOKIE_REFRESH_POLL_MS,
COOKIE_REFRESH_TIMEOUT_MS: contracts.COOKIE_REFRESH_TIMEOUT_MS,
OPPORTUNITIES_LIST_URL: contracts.OPPORTUNITIES_LIST_URL,
getXsrfTokenCookie: (...args) => authXsrfCookies.getXsrfTokenCookie(...args),
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
wait: (...args) => platformBackgroundApi.wait(...args)
});
const authXsrfCookies = createAuthXsrfCookies({
ORACLE_COOKIE_URLS: contracts.ORACLE_COOKIE_URLS,
ORACLE_DOMAIN: contracts.ORACLE_DOMAIN,
XSRF_COOKIE_NAME: contracts.XSRF_COOKIE_NAME,
XSRF_COOKIE_PREFIX: contracts.XSRF_COOKIE_PREFIX,
cookiesGet: (...args) => platformBackgroundApi.cookiesGet(...args),
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
cookiesGetAllCookieStores: (...args) => platformBackgroundApi.cookiesGetAllCookieStores(...args),
removeUndefinedValues: (...args) => platformBackgroundApi.removeUndefinedValues(...args)
});
const platformBackgroundApi = createPlatformBackgroundApi({
runtimeApi: environment.browser || environment.chrome,
environment
});
const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args);
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
function start() {
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,
productsCacheState,
authConsumerSession,
authSpaCredentials,
spaAccounts,
spaConsumption,
spaResponseShapes,
httpBackgroundJson,
productsBackgroundJobs,
productsCache,
fusionProducts,
authXsrfRecovery,
authXsrfCookies,
platformBackgroundApi
};
}

View File

@@ -0,0 +1,69 @@
function createBackgroundContracts() {
const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com";
const ORACLE_COOKIE_URLS = [
"https://eeho.fa.us2.oraclecloud.com/",
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome",
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome",
"https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay"
];
const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F";
const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-";
const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list";
const COOKIE_REFRESH_TIMEOUT_MS = 15000;
const COOKIE_REFRESH_POLL_MS = 250;
const OPPTY_PRODUCTS_CACHE_KEY = "opportunitiesExtension.opptyProductsCache.v1";
const OPPTY_PRODUCTS_PAGE_LIMIT = 15;
const OPPTY_PRODUCTS_MAX_PAGES = 250;
const OPPTY_PRODUCTS_CONCURRENCY = 4;
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService";
const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html";
const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5;
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;
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"
];
return { ORACLE_DOMAIN, ORACLE_COOKIE_URLS, XSRF_COOKIE_NAME, XSRF_COOKIE_PREFIX, OPPORTUNITIES_LIST_URL, COOKIE_REFRESH_TIMEOUT_MS, COOKIE_REFRESH_POLL_MS, OPPTY_PRODUCTS_CACHE_KEY, OPPTY_PRODUCTS_PAGE_LIMIT, OPPTY_PRODUCTS_MAX_PAGES, OPPTY_PRODUCTS_CONCURRENCY, CUSTOMER_TOKEN_SERVICE_URL, CUSTOMER_TOKEN_SESSION_URL, CUSTOMER_TOKEN_MAX_ATTEMPTS, CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS, CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS, ACCOUNT_SUMMARY_URL, ACCOUNT_PLANS_URL, CONSUMPTION_TREND_URL, CONSUMPTION_TREND_REQUESTS, ACCOUNT_PLANS_PAYLOAD, DEBUG_BODY_LIMIT, OPPTY_PRODUCTS_FIELDS };
}

3
src/background/main.js Normal file
View File

@@ -0,0 +1,3 @@
function startBackgroundApplication(dependencies) {
registerBackgroundMessages(dependencies);
}

116
src/background/messages.js Normal file
View File

@@ -0,0 +1,116 @@
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
messageTypes,
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestConsumerTokenService,
requestConsumptionTrend,
requestOpptyProducts,
runtimeApi
}) {
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) {
return false;
}
if (message.type === messageTypes.getXsrfToken) {
getXsrfTokenCookie()
.then((result) => {
sendResponse({
ok: true,
cookieName: result.cookie ? result.cookie.name : "",
token: result.cookie ? result.cookie.value : "",
matchedCookieNames: result.matchedCookieNames,
lookupDetails: result.lookupDetails
});
})
.catch((error) => {
sendResponse({
ok: false,
cookieName: "",
token: "",
matchedCookieNames: [],
lookupDetails: [],
error: error.message || "Unable to read cookies."
});
});
return true;
}
if (message.type === messageTypes.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 === messageTypes.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 === messageTypes.requestConsumerTokenService) {
requestConsumerTokenService(sender)
.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 === messageTypes.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 === messageTypes.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;
});
}

File diff suppressed because it is too large Load Diff

434
src/content/application.js Normal file
View File

@@ -0,0 +1,434 @@
function createContentApplication(overrides = {}) {
const environment = {
document: globalThis.document,
window: globalThis.window,
navigator: globalThis.navigator,
get localStorage() { return globalThis.localStorage; },
fetch: globalThis.fetch,
browser: globalThis.browser,
chrome: globalThis.chrome,
MutationObserver: globalThis.MutationObserver,
Node: globalThis.Node,
requestAnimationFrame: globalThis.requestAnimationFrame,
performance: globalThis.performance,
cancelAnimationFrame: globalThis.cancelAnimationFrame,
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
matchMedia: globalThis.matchMedia,
...overrides
};
const messageTypes = createMessageTypes();
const contracts = createContentContracts();
const authSessionState = createAuthSessionState(contracts);
const authSalesCommonState = createAuthSalesCommonState();
const opportunitiesQueryState = createOpportunitiesQueryState();
const opportunitiesTableState = createOpportunitiesTableState();
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
const preferencesState = createPreferencesState(contracts);
const productsContentState = createProductsContentState();
const aiSummaryState = createAiSummaryState();
const consumptionPlansState = createConsumptionPlansState();
const consumptionTrendState = createConsumptionTrendState();
const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts);
const debugState = createDebugState();
const columns = createOpportunitiesColumns({
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args),
formatWinProbability: (...args) => opportunitiesFormatters.formatWinProbability(...args),
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
getOwnerName: (...args) => opportunitiesFormatters.getOwnerName(...args)
});
const contentFusionPage = createContentFusionPage({
ADD_ICON_SELECTOR: contracts.ADD_ICON_SELECTOR,
ALLOWED_PATHS: contracts.ALLOWED_PATHS,
SALES_GROUP_SELECTOR: contracts.SALES_GROUP_SELECTOR,
TILE_ID: contracts.TILE_ID,
TILE_LABEL_ID: contracts.TILE_LABEL_ID,
appendPath: (...args) => svgPaths.appendPath(...args),
openOpportunitiesModal: (...args) => contentModal.openOpportunitiesModal(...args),
environment
});
const contentModal = createContentModal({
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
AUTH_STATUS: contracts.AUTH_STATUS,
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID,
CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID,
DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID,
ESCAPE_LISTENER_KEY: contracts.ESCAPE_LISTENER_KEY,
MODAL_ID: contracts.MODAL_ID,
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID,
OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID,
OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID,
OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID,
OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID,
PERIOD_OPTIONS: contracts.PERIOD_OPTIONS,
PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID,
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID,
STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID,
STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID,
STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID,
TABLE_SEARCH_ID: contracts.TABLE_SEARCH_ID,
applyTheme: (...args) => contentTheme.applyTheme(...args),
authSessionState,
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
debugState,
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args),
getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args),
getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args),
getSavedTheme: (...args) => contentTheme.getSavedTheme(...args),
openPreferencesModal: (...args) => preferencesView.openPreferencesModal(...args),
opportunitiesFilterState,
opportunitiesQueryState,
preferencesState,
renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args),
renderCustomerFilterList: (...args) => opportunitiesFilters.renderCustomerFilterList(...args),
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args),
renderOwnerFilterList: (...args) => opportunitiesFilters.renderOwnerFilterList(...args),
renderStageFilterButtons: (...args) => opportunitiesFilters.renderStageFilterButtons(...args),
renderStatusFilterButtons: (...args) => opportunitiesFilters.renderStatusFilterButtons(...args),
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args),
resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args),
restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args),
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args),
toggleDebugPanel: (...args) => debugContentView.toggleDebugPanel(...args),
toggleOwnerFilterPanel: (...args) => opportunitiesFilters.toggleOwnerFilterPanel(...args),
environment
});
const contentTheme = createContentTheme({
THEME_STORAGE_KEY: contracts.THEME_STORAGE_KEY,
createThemeIcon: (...args) => contentIcons.createThemeIcon(...args),
environment
});
const contentIcons = createContentIcons({
appendPath: (...args) => svgPaths.appendPath(...args),
environment
});
const svgPaths = createSvgPaths({ environment });
const contentStyles = createContentStyles({
STYLE_ID: contracts.STYLE_ID,
redwoodStyles,
environment
});
const authContentSession = createAuthContentSession({
messageTypes,
AUTH_STATUS: contracts.AUTH_STATUS,
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
authSessionState,
requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
updateAuthBadge: (...args) => contentModal.updateAuthBadge(...args)
});
const authSalesCommon = createAuthSalesCommon({
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
authSalesCommonState,
requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args)
});
const authTokenRelay = createAuthTokenRelay({
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
createDebugBody: (...args) => debugContentLog.createDebugBody(...args),
getXsrfToken: (...args) => authXsrfContent.getXsrfToken(...args),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
maskToken: (...args) => debugContentLog.maskToken(...args),
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
refreshXsrfCookie: (...args) => platformContentMessages.refreshXsrfCookie(...args),
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args),
environment
});
const authXsrfContent = createAuthXsrfContent({
getXsrfTokenFromExtensionCookies: (...args) => platformContentMessages.getXsrfTokenFromExtensionCookies(...args),
environment
});
const platformContentMessages = createPlatformContentMessages({
messageTypes,
environment
});
const opportunitiesController = createOpportunitiesController({
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
authSessionState,
consumptionPlansState,
consumptionTrendState,
getFiscalPeriodRange: (...args) => opportunitiesFiscalPeriods.getFiscalPeriodRange(...args),
opportunitiesDashboardState,
opportunitiesFilterState,
opportunitiesQueryState,
opportunitiesTableState,
preferencesState,
productsContentState,
renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args),
requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args),
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
});
const fusionOpportunities = createFusionOpportunities({
OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT,
OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL,
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
maskToken: (...args) => debugContentLog.maskToken(...args),
getOpportunityTypeValues: () => preferencesState.selectedOpportunityTypeValues,
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
environment
});
const opportunitiesFiscalPeriods = createOpportunitiesFiscalPeriods();
const opportunitiesTable = createOpportunitiesTable({
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
OPPORTUNITIES_COLUMNS: columns,
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
OPPORTUNITY_DETAIL_URL: contracts.OPPORTUNITY_DETAIL_URL,
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
createStatusBadge: (...args) => opportunitiesLinks.createStatusBadge(...args),
displayOpportunityValue: (...args) => opportunitiesFormatters.displayOpportunityValue(...args),
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
getSortedOpportunities: (...args) => opportunitiesFilters.getSortedOpportunities(...args),
opportunitiesTableState,
productsContentState,
renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args),
renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args),
renderStageDashboard: (...args) => opportunitiesDashboard.renderStageDashboard(...args),
resetCustomerFilter: (...args) => opportunitiesFilters.resetCustomerFilter(...args),
resetOwnerFilter: (...args) => opportunitiesFilters.resetOwnerFilter(...args),
environment
});
const opportunitiesDashboard = createOpportunitiesDashboard({
STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID,
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
opportunitiesDashboardState,
environment
});
const opportunitiesFilters = createOpportunitiesFilters({
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID,
CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID,
OPPORTUNITIES_COLUMNS: columns,
OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID,
OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID,
OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID,
OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID,
OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID,
STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID,
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID,
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
getCustomerFilterSummary: (...args) => opportunitiesFormatters.getCustomerFilterSummary(...args),
getCustomerKey: (...args) => opportunitiesFormatters.getCustomerKey(...args),
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
getOwnerKey: (...args) => opportunitiesFormatters.getOwnerKey(...args),
getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args),
opportunitiesFilterState,
opportunitiesTableState,
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
stringifySearchValue: (...args) => opportunitiesFormatters.stringifySearchValue(...args),
environment
});
const opportunitiesFormatters = createOpportunitiesFormatters({
opportunitiesTableState
});
const opportunitiesLinks = createOpportunitiesLinks({
MODAL_ID: contracts.MODAL_ID,
environment
});
const preferencesStorage = createPreferencesStorage({
DEFAULT_OPPORTUNITY_TYPE_VALUES: contracts.DEFAULT_OPPORTUNITY_TYPE_VALUES,
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
FILTER_PREFERENCES_STORAGE_KEY: contracts.FILTER_PREFERENCES_STORAGE_KEY,
MODAL_ID: contracts.MODAL_ID,
OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS,
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS,
PERIOD_OPTIONS: contracts.PERIOD_OPTIONS,
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
opportunitiesFilterState,
environment
});
const preferencesView = createPreferencesView({
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
MODAL_ID: contracts.MODAL_ID,
OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS,
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS,
PERIOD_OPTIONS: contracts.PERIOD_OPTIONS,
PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args),
getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args),
preferencesState,
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
environment
});
const productsContentJobs = createProductsContentJobs({
messageTypes,
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
PRODUCTS_MESSAGE_LISTENER_KEY: contracts.PRODUCTS_MESSAGE_LISTENER_KEY,
PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID,
preferencesState,
productsContentState,
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
environment
});
const productsDetails = createProductsDetails({
OPPORTUNITIES_COLUMNS: columns,
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
productsContentState,
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args),
environment
});
const aiSummaryContent = createAiSummaryContent({
aiSummaryState,
invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args),
environment
});
const consumptionPlansAction = createConsumptionPlansAction({
messageTypes,
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
consumptionPlansState,
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
openAccountPlansModal: (...args) => consumptionPlansView.openAccountPlansModal(...args),
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
showCopyConfirmation: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
environment
});
const consumptionPlansView = createConsumptionPlansView({
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
createConsumptionTrendSection: (...args) => consumptionTrendController.createConsumptionTrendSection(...args),
createSvgElement: (...args) => consumptionChart.createSvgElement(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
loadConsumptionTrendForPanel: (...args) => consumptionTrendController.loadConsumptionTrendForPanel(...args),
environment
});
const consumptionTrendController = createConsumptionTrendController({
messageTypes,
CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS,
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
consumptionTrendState,
renderConsumptionTrendChart: (...args) => consumptionChart.renderConsumptionTrendChart(...args),
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
environment
});
const consumptionChart = createConsumptionChart({
CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS,
CONSUMPTION_TREND_SERIES: contracts.CONSUMPTION_TREND_SERIES,
SVG_NAMESPACE: contracts.SVG_NAMESPACE,
calculateAverageConsumption: (...args) => consumptionModel.calculateAverageConsumption(...args),
consumptionTrendState,
createConsumptionTrendKey: (...args) => consumptionTrendController.createConsumptionTrendKey(...args),
createNiceConsumptionMaximum: (...args) => consumptionModel.createNiceConsumptionMaximum(...args),
formatCompactConsumptionNumber: (...args) => consumptionModel.formatCompactConsumptionNumber(...args),
formatConsumptionCurrency: (...args) => consumptionModel.formatConsumptionCurrency(...args),
formatConsumptionPointLabel: (...args) => consumptionModel.formatConsumptionPointLabel(...args),
formatConsumptionRange: (...args) => consumptionModel.formatConsumptionRange(...args),
normalizeConsumptionTrendItems: (...args) => consumptionModel.normalizeConsumptionTrendItems(...args),
environment
});
const consumptionModel = createConsumptionModel({
});
const debugContentLog = createDebugContentLog({
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
debugState,
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args)
});
const debugContentView = createDebugContentView({
DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID,
MODAL_ID: contracts.MODAL_ID,
debugState,
formatDebugBody: (...args) => debugContentLog.formatDebugBody(...args),
formatHeaderBlock: (...args) => debugContentLog.formatHeaderBlock(...args),
formatMetadataBlock: (...args) => debugContentLog.formatMetadataBlock(...args),
environment
});
function start() {
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
}
return {
start, contracts,
authSessionState,
authSalesCommonState,
opportunitiesQueryState,
opportunitiesTableState,
opportunitiesFilterState,
preferencesState,
productsContentState,
aiSummaryState,
consumptionPlansState,
consumptionTrendState,
opportunitiesDashboardState,
debugState,
contentFusionPage,
contentModal,
contentTheme,
contentIcons,
contentStyles,
authContentSession,
authSalesCommon,
authTokenRelay,
authXsrfContent,
platformContentMessages,
opportunitiesController,
fusionOpportunities,
opportunitiesFiscalPeriods,
opportunitiesTable,
opportunitiesDashboard,
opportunitiesFilters,
opportunitiesFormatters,
opportunitiesLinks,
preferencesStorage,
preferencesView,
productsContentJobs,
productsDetails,
aiSummaryContent,
consumptionPlansAction,
consumptionPlansView,
consumptionTrendController,
consumptionChart,
consumptionModel,
debugContentLog,
debugContentView
};
}

100
src/content/contracts.js Normal file
View File

@@ -0,0 +1,100 @@
function createContentContracts() {
const TILE_ID = "c_5c719a88b5624b1eaf81defe2c2ex4x5";
const TILE_LABEL_ID = `${TILE_ID}_0`;
const SALES_GROUP_SELECTOR = "#yourapps_groupNode_sales";
const ADD_ICON_SELECTOR = ".flat-grid-cell.flat-grid-cell-addicon";
const MODAL_ID = "opportunities-extension-modal";
const STYLE_ID = "opportunities-extension-redwood-styles";
const ESCAPE_LISTENER_KEY = "opportunitiesExtensionEscapeListener";
const DEBUG_PANEL_ID = "opportunities-extension-debug-panel";
const OPPORTUNITIES_TABLE_ID = "opportunities-extension-table";
const STAGE_FILTERS_ID = "opportunities-extension-stage-filters";
const STATUS_FILTERS_ID = "opportunities-extension-status-filters";
const CUSTOMER_FILTER_BUTTON_ID = "opportunities-extension-customer-filter-button";
const CUSTOMER_FILTER_PANEL_ID = "opportunities-extension-customer-filter-panel";
const CUSTOMER_FILTER_SEARCH_ID = "opportunities-extension-customer-filter-search";
const CUSTOMER_FILTER_SELECT_ALL_ID = "opportunities-extension-customer-filter-select-all";
const CUSTOMER_FILTER_LIST_ID = "opportunities-extension-customer-filter-list";
const OWNER_FILTER_BUTTON_ID = "opportunities-extension-owner-filter-button";
const OWNER_FILTER_PANEL_ID = "opportunities-extension-owner-filter-panel";
const OWNER_FILTER_SEARCH_ID = "opportunities-extension-owner-filter-search";
const OWNER_FILTER_SELECT_ALL_ID = "opportunities-extension-owner-filter-select-all";
const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list";
const TABLE_SEARCH_ID = "opportunities-extension-table-search";
const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard";
const PRODUCTS_PROGRESS_ID = "opportunities-extension-products-progress";
const PRODUCTS_CACHE_TIMESTAMP_ID = "opportunities-extension-products-cache-timestamp";
const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences";
const THEME_STORAGE_KEY = "opportunities-extension-theme";
const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences";
const PREFERENCES_MODAL_ID = "opportunities-extension-preferences-modal";
const ACCOUNT_PLANS_MODAL_ID = "opportunities-extension-account-plans-modal";
const PRODUCTS_MESSAGE_LISTENER_KEY = "opportunitiesExtensionProductsMessageListener";
const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid=";
const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id=";
const DEBUG_BODY_LIMIT = 12000;
const OPPORTUNITIES_PAGE_LIMIT = 15;
const DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS = 24;
const TOKEN_RELAY_URL = "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay";
const OPPORTUNITIES_QUERY_URL = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/11.13.18.05/custom-actions/queries";
const AUTH_STATUS = {
idle: "idle",
pending: "pending",
authenticated: "authenticated",
unauthenticated: "unauthenticated"
};
const CONSUMPTION_TREND_PERIODS = [
{ value: "quarterly", label: "Quarterly", windowSize: 8 },
{ value: "monthly", label: "Monthly", windowSize: 13 },
{ value: "weekly", label: "Weekly", windowSize: 13 },
{ value: "daily", label: "Daily", windowSize: 30 }
];
const CONSUMPTION_TREND_SERIES = [
{ value: "funded", label: "Funded Allocation" },
{ value: "overage", label: "Overage" },
{ value: "average", label: "Average Consumption" }
];
const DEFAULT_CONSUMPTION_TREND_PERIOD = "weekly";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"];
const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"];
const OPPORTUNITY_TYPE_OPTIONS = [
{ label: "All records I can see", value: "ORA_ALLOPTIES" },
{ label: "I am credit receiver", value: "ORA_CREDITRECEIVER_ISME" },
{ label: "I am on the team", value: "ORA_MYSALESTEAMOPTIES" },
{ label: "I or my subordinate is a credit receiver", value: "ORA_CREDITRECEIVER_MYORG" },
{ label: "I own", value: "ORA_MYOPTIES" },
{ label: "My subordinates are on the team", value: "ORA_MYSUBORDSSALESTEAMOPTIES" },
{ label: "My subordinates own", value: "ORA_MYSUBORDINATESOPTIES" },
{ label: "My territory", value: "ORA_MYASSGTERROPTIES" },
{ label: "My territory hierarchy", value: "ORA_MYASSGDESCTERROPTIES" },
{ label: "My territory membership", value: "ORA_MYASSGMEMBTERROPTIES" },
{ label: "My territory membership hierarchy", value: "ORA_MYASSGMEMBDESCTERROPTIES" }
];
const DEFAULT_OPPORTUNITY_TYPE_VALUES = [
"ORA_MYSALESTEAMOPTIES",
"ORA_MYASSGTERROPTIES",
"ORA_CREDITRECEIVER_ISME"
];
const OPPTY_PRODUCTS_CACHE_OPTIONS = [
{ label: "1 hour", value: 1 },
{ label: "6 hours", value: 6 },
{ label: "12 hours", value: 12 },
{ label: "24 hours (default)", value: 24 },
{ label: "48 hours", value: 48 },
{ label: "7 days", value: 168 }
];
const PERIOD_OPTIONS = [
"Current Quarter",
"Next Quarter",
"Previous Quarter",
"Current Fiscal Year",
"4 Rolling Quarters (CQ + 3)",
"RENEWALS (Current + Past Due)"
];
const ALLOWED_PATHS = [
"/hcmUI/faces/FuseWelcome",
"/fscmUI/faces/FuseWelcome"
];
return { TILE_ID, TILE_LABEL_ID, SALES_GROUP_SELECTOR, ADD_ICON_SELECTOR, MODAL_ID, STYLE_ID, ESCAPE_LISTENER_KEY, DEBUG_PANEL_ID, OPPORTUNITIES_TABLE_ID, STAGE_FILTERS_ID, STATUS_FILTERS_ID, CUSTOMER_FILTER_BUTTON_ID, CUSTOMER_FILTER_PANEL_ID, CUSTOMER_FILTER_SEARCH_ID, CUSTOMER_FILTER_SELECT_ALL_ID, CUSTOMER_FILTER_LIST_ID, OWNER_FILTER_BUTTON_ID, OWNER_FILTER_PANEL_ID, OWNER_FILTER_SEARCH_ID, OWNER_FILTER_SELECT_ALL_ID, OWNER_FILTER_LIST_ID, TABLE_SEARCH_ID, STAGE_DASHBOARD_ID, PRODUCTS_PROGRESS_ID, PRODUCTS_CACHE_TIMESTAMP_ID, FILTER_PREFERENCES_STORAGE_KEY, THEME_STORAGE_KEY, OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, PREFERENCES_MODAL_ID, ACCOUNT_PLANS_MODAL_ID, PRODUCTS_MESSAGE_LISTENER_KEY, OPPORTUNITY_DETAIL_URL, ACCOUNT_DETAIL_URL, DEBUG_BODY_LIMIT, OPPORTUNITIES_PAGE_LIMIT, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, TOKEN_RELAY_URL, OPPORTUNITIES_QUERY_URL, AUTH_STATUS, CONSUMPTION_TREND_PERIODS, CONSUMPTION_TREND_SERIES, DEFAULT_CONSUMPTION_TREND_PERIOD, SVG_NAMESPACE, STAGE_OPTIONS, STATUS_OPTIONS, OPPORTUNITY_TYPE_OPTIONS, DEFAULT_OPPORTUNITY_TYPE_VALUES, OPPTY_PRODUCTS_CACHE_OPTIONS, PERIOD_OPTIONS, ALLOWED_PATHS };
}

107
src/content/fusion-page.js Normal file
View File

@@ -0,0 +1,107 @@
function createContentFusionPage({
ADD_ICON_SELECTOR,
ALLOWED_PATHS,
SALES_GROUP_SELECTOR,
TILE_ID,
TILE_LABEL_ID,
appendPath,
openOpportunitiesModal,
environment
}) {
const { Node, document, window } = environment;
function createOpportunitiesTile() {
const wrapper = document.createElement("div");
wrapper.className = "flat-grid-cell";
const item = document.createElement("div");
item.id = TILE_ID;
item.className = "app-nav-item opportunities-extension-tile";
item.setAttribute("filmstrip", "Opportunities Extension");
item.setAttribute("page", "undefined");
item.setAttribute("index", "0");
item.setAttribute("type", "subcluster");
item.setAttribute("title", "Opportunities Extension");
item.setAttribute("group", "groupNode_tools");
item.setAttribute("destinationurl", "https://gxpap.oracle.com/ords/pgxpap/f?p=138");
item.setAttribute("targetframe", "_blank");
item.setAttribute("isdesturlexist", "true");
item.setAttribute("role", "presentation");
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 48 48");
svg.setAttribute("style", "fill:currentColor");
svg.setAttribute("class", "svg-nav suiicon svg-bkgd09");
svg.setAttribute("data-icon", "navi_reportsearch");
svg.setAttribute("role", "presentation");
svg.setAttribute("focusable", "false");
appendPath(svg, "svg-shortcut", "M28 42.5l-3 2.7v-1.7c-.4 0-1.4 0-2.5.6-1.3 1-1.5 1.6-1.5 1.6s-.4-1.2.8-2.7c1.2-1.6 2.6-1.7 3.2-1.6v-1.6l3 2.7z");
appendPath(svg, "svg-cluster", "M28.5 41.3c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2zm-4 0c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2c-.7 0-1.2-.5-1.2-1.2s.5-1.2 1.2-1.2zm-4 0c.7 0 1.2.5 1.2 1.2s-.5 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2z");
appendPath(svg, "svg-icon15", "M16 31l-1.6-1-3.4 6.5s0 1 .5 1.4c.5.2 1.4-.4 1.4-.4l3-6.7z");
appendPath(svg, "svg-icon03", "M36 10H12c-.8 0-2 1.2-2 2v20c0 .4.2.8.5 1l2-3.6c-1-1.4-1.6-3-1.6-5 0-4.2 3.3-7.6 7.4-7.6H20V16h2v1.7c.7.4 1.3 1 1.8 1.5.6.5 1 1 1.3 1.8h4v7h-4c-.3.7-.8 1.4-1.4 2H35v2H20.3l-2 .2H18L17 34h19c.8 0 2-1.2 2-2V12c0-.8-1.2-2-2-2zm-23 4v-2h22v2H13zm22 14h-5V17h5v11z");
appendPath(svg, "svg-icon12", "M18.5 19c-3 0-5.5 2.5-5.5 5.5s2.5 5.5 5.5 5.5 5.5-2.5 5.5-5.5-2.5-5.5-5.5-5.5zm0 9c-2 0-3.5-1.6-3.5-3.5 0-2 1.6-3.5 3.5-3.5s3.5 1.6 3.5 3.5c0 2-1.6 3.5-3.5 3.5z");
appendPath(svg, "svg-outline", "M35 34.56H13a2.7 2.7 0 0 1-3-3V14a2.76 2.76 0 0 1 3-3h22a2.74 2.74 0 0 1 3 3v17.56a2.68 2.68 0 0 1-3 3zM16.98 22.32a4.72 4.72 0 1 0 4.73 4.73 4.72 4.72 0 0 0-4.73-4.73zM24 25h3.47v4.72H24V25zm5.78-4.66h4.69v9.38h-4.69v-9.38zM13.5 14.5h20.9v2.4H13.5v-2.4zm6.9 17.59l-.01-1.94zm-2.04-12.75l-5.35-.02zm2.13 12.67H35.7zm-.09-8.05L20.39 19z");
appendPath(svg, "svg-outline", "M16.98 31.72a4.68 4.68 0 1 0-4.75-4.67 4.74 4.74 0 0 0 4.75 4.67zm-1.44-.5l-3.6 6.83zM8 40");
const link = document.createElement("a");
link.id = TILE_LABEL_ID;
link.className = "app-nav-label flat-grid-nav-label";
link.href = "#";
link.textContent = "Opportunities Extension";
item.append(svg, link);
wrapper.append(item);
wrapper.addEventListener("click", handleTileClick);
return wrapper;
}
function handleTileClick(event) {
event.preventDefault();
event.stopPropagation();
openOpportunitiesModal();
}
function isAllowedPage() {
if (window.location.hostname !== "eeho.fa.us2.oraclecloud.com") {
return false;
}
return ALLOWED_PATHS.some((path) => {
return window.location.pathname === path || window.location.pathname.startsWith(`${path}/`);
});
}
function findInsertionPoint(salesGroup) {
const addIconInsideGroup = salesGroup.querySelector(ADD_ICON_SELECTOR);
if (addIconInsideGroup) {
return addIconInsideGroup;
}
const container = salesGroup.closest(".flat-grid, .flat-grid-container, [id*='yourapps'], [id*='groupNode']") || salesGroup.parentElement || document.body;
const addIconCells = Array.from(container.querySelectorAll(ADD_ICON_SELECTOR));
return addIconCells.find((cell) => {
return Boolean(salesGroup.compareDocumentPosition(cell) & Node.DOCUMENT_POSITION_FOLLOWING);
}) || null;
}
function insertTile() {
if (document.getElementById(TILE_ID)) {
return true;
}
const salesGroup = document.querySelector(SALES_GROUP_SELECTOR);
const addIconCell = salesGroup ? findInsertionPoint(salesGroup) : null;
if (!salesGroup || !addIconCell) {
return false;
}
addIconCell.before(createOpportunitiesTile());
return true;
}
return { createOpportunitiesTile, handleTileClick, isAllowedPage, findInsertionPoint, insertTile };
}

74
src/content/icons.js Normal file
View File

@@ -0,0 +1,74 @@
function createContentIcons({
appendPath,
environment
}) {
const { document } = environment;
function createThemeIcon(iconName) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
if (iconName === "sun") {
path.setAttribute("d", "M12 3V1m0 22v-2m9-9h2M1 12h2m15.36 6.36 1.42 1.42M4.22 4.22l1.42 1.42m12.72 0 1.42-1.42M4.22 19.78l1.42-1.42M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z");
} else {
path.setAttribute("d", "M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5a8.5 8.5 0 1 0 10.7 10.7Z");
}
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
path.setAttribute("stroke-width", "1.8");
svg.append(path);
return svg;
}
function createSettingsIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const upperControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
const lowerControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("class", "opportunities-extension-settings-icon");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
appendPath(svg, "", "M4 7h16M4 17h16");
[upperControl, lowerControl].forEach((control, index) => {
control.setAttribute("cx", index === 0 ? "9" : "15");
control.setAttribute("cy", index === 0 ? "7" : "17");
control.setAttribute("r", "2.5");
control.setAttribute("fill", "currentColor");
control.setAttribute("stroke", "currentColor");
control.setAttribute("stroke-width", "1.8");
});
svg.querySelector("path").setAttribute("fill", "none");
svg.querySelector("path").setAttribute("stroke", "currentColor");
svg.querySelector("path").setAttribute("stroke-linecap", "round");
svg.querySelector("path").setAttribute("stroke-width", "1.8");
svg.append(upperControl, lowerControl);
return svg;
}
function createCloseIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
path.setAttribute("d", "m6 6 12 12M18 6 6 18");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-width", "1.8");
svg.append(path);
return svg;
}
return { createThemeIcon, createSettingsIcon, createCloseIcon };
}

9
src/content/main.js Normal file
View File

@@ -0,0 +1,9 @@
/** Preserve the existing document_idle initialization and observer behavior. */
function startContentApplication({ environment, page, styles, products }) {
if (!page.isAllowedPage()) return;
styles.ensureExtensionStyles();
products.ensureProductsBackgroundMessageHandler();
if (page.insertTile()) return;
const observer = new environment.MutationObserver(() => page.insertTile());
observer.observe(environment.document.documentElement, { childList: true, subtree: true });
}

521
src/content/modal.js Normal file
View File

@@ -0,0 +1,521 @@
function createContentModal({
ACCOUNT_PLANS_MODAL_ID,
AUTH_STATUS,
CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID,
CUSTOMER_FILTER_PANEL_ID,
CUSTOMER_FILTER_SEARCH_ID,
CUSTOMER_FILTER_SELECT_ALL_ID,
DEBUG_PANEL_ID,
ESCAPE_LISTENER_KEY,
MODAL_ID,
OPPORTUNITIES_TABLE_ID,
OWNER_FILTER_BUTTON_ID,
OWNER_FILTER_LIST_ID,
OWNER_FILTER_PANEL_ID,
OWNER_FILTER_SEARCH_ID,
OWNER_FILTER_SELECT_ALL_ID,
PERIOD_OPTIONS,
PREFERENCES_MODAL_ID,
PRODUCTS_CACHE_TIMESTAMP_ID,
PRODUCTS_PROGRESS_ID,
STAGE_DASHBOARD_ID,
STAGE_FILTERS_ID,
STATUS_FILTERS_ID,
TABLE_SEARCH_ID,
applyTheme,
authSessionState,
closeAccountPlansModal,
closeCustomerFilterPanel,
closeOwnerFilterPanel,
closePreferencesModal,
createSettingsIcon,
debugState,
ensureExtensionStyles,
getCustomerOptions,
getOwnerOptions,
getSavedOpportunityTypeValues,
getSavedOpptyProductsCacheHours,
getSavedTheme,
openPreferencesModal,
opportunitiesFilterState,
opportunitiesQueryState,
preferencesState,
renderCustomerFilter,
renderCustomerFilterList,
renderOpportunitiesTable,
renderOwnerFilter,
renderOwnerFilterList,
renderStageFilterButtons,
renderStatusFilterButtons,
requestOpportunitiesForPeriod,
requestTokenRelayOnce,
resetCustomerAndOwnerFiltersForPeriod,
resetOpportunitiesTable,
restoreFilterPreferences,
saveFilterPreferences,
toggleCustomerFilterPanel,
toggleDebugPanel,
toggleOwnerFilterPanel,
environment
}) {
const { document, window } = environment;
function openOpportunitiesModal() {
ensureExtensionStyles();
debugState.debugSecretsVisible = false;
const existingModal = document.getElementById(MODAL_ID);
if (existingModal) {
existingModal.hidden = false;
existingModal.querySelector("select").focus();
document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler();
requestTokenRelayOnce().catch(() => {});
return;
}
const overlay = document.createElement("div");
debugState.requestLog = [];
authSessionState.tokenRelayRequest = null;
authSessionState.consumerTokenServiceRequest = null;
authSessionState.accessToken = "";
opportunitiesQueryState.periodRequestVersion = 0;
authSessionState.authStatus = AUTH_STATUS.idle;
resetOpportunitiesTable();
preferencesState.selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues());
preferencesState.opptyProductsCacheHours = getSavedOpptyProductsCacheHours();
overlay.id = MODAL_ID;
overlay.className = "opportunities-extension-modal";
overlay.setAttribute("data-theme", getSavedTheme());
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
overlay.setAttribute("aria-describedby", "opportunities-extension-subtitle");
const shell = document.createElement("section");
shell.className = "opportunities-extension-shell";
const header = document.createElement("header");
header.className = "opportunities-extension-header";
const title = document.createElement("h1");
title.id = "opportunities-extension-title";
title.textContent = "Opportunities Extension";
const subtitle = document.createElement("p");
subtitle.id = "opportunities-extension-subtitle";
subtitle.textContent = "Lista de oportunidades do HCM Opportunities List";
const authBadge = document.createElement("span");
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
authBadge.textContent = "Authenticating";
const subtitleRow = document.createElement("div");
subtitleRow.className = "opportunities-extension-subtitle-row";
const productsProgressBadge = document.createElement("span");
productsProgressBadge.id = PRODUCTS_PROGRESS_ID;
productsProgressBadge.className = "opportunities-extension-products-progress";
productsProgressBadge.setAttribute("role", "status");
productsProgressBadge.hidden = true;
const productsCacheTimestamp = document.createElement("span");
productsCacheTimestamp.id = PRODUCTS_CACHE_TIMESTAMP_ID;
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
productsCacheTimestamp.setAttribute("role", "status");
productsCacheTimestamp.hidden = true;
subtitleRow.append(subtitle, authBadge, productsProgressBadge, productsCacheTimestamp);
const titleBlock = document.createElement("div");
titleBlock.append(title, subtitleRow);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Fechar");
closeButton.textContent = "X";
closeButton.addEventListener("click", closeOpportunitiesModal);
const themeButton = document.createElement("button");
themeButton.type = "button";
themeButton.className = "opportunities-extension-icon-button opportunities-extension-theme-toggle";
themeButton.addEventListener("click", () => {
const nextTheme = overlay.getAttribute("data-theme") === "dark" ? "light" : "dark";
applyTheme(overlay, themeButton, nextTheme);
});
applyTheme(overlay, themeButton, overlay.getAttribute("data-theme"));
const preferencesButton = document.createElement("button");
preferencesButton.type = "button";
preferencesButton.className = "opportunities-extension-icon-button";
preferencesButton.setAttribute("aria-label", "Abrir preferências");
preferencesButton.title = "Preferences";
preferencesButton.append(createSettingsIcon());
preferencesButton.addEventListener("click", openPreferencesModal);
const headerActions = document.createElement("div");
headerActions.className = "opportunities-extension-header-actions";
headerActions.append(themeButton, preferencesButton, closeButton);
header.append(titleBlock, headerActions);
const body = document.createElement("main");
body.className = "opportunities-extension-body";
const form = document.createElement("form");
form.className = "opportunities-extension-form";
const field = document.createElement("label");
field.className = "opportunities-extension-field";
const labelText = document.createElement("span");
labelText.textContent = "Reference";
const selectWrap = document.createElement("span");
selectWrap.className = "opportunities-extension-select-wrap";
const select = document.createElement("select");
select.name = "opportunityPeriod";
PERIOD_OPTIONS.forEach((optionLabel) => {
const option = document.createElement("option");
option.value = optionLabel;
option.textContent = optionLabel;
select.append(option);
});
select.addEventListener("change", () => {
resetCustomerAndOwnerFiltersForPeriod();
saveFilterPreferences(select.value);
requestOpportunitiesForPeriod(select.value);
});
selectWrap.append(select);
field.append(labelText, selectWrap);
restoreFilterPreferences(select);
const stageFilter = document.createElement("section");
stageFilter.className = "opportunities-extension-stage-filter";
const stageFilterLabel = document.createElement("span");
stageFilterLabel.className = "opportunities-extension-stage-filter-label";
stageFilterLabel.textContent = "Stage";
const stageFilters = document.createElement("div");
stageFilters.id = STAGE_FILTERS_ID;
stageFilters.className = "opportunities-extension-stage-filter-controls";
stageFilters.setAttribute("role", "group");
stageFilters.setAttribute("aria-label", "Filter by stage");
stageFilter.append(stageFilterLabel, stageFilters);
const customerFilter = document.createElement("section");
customerFilter.className = "opportunities-extension-customer-filter";
const customerFilterLabel = document.createElement("span");
customerFilterLabel.className = "opportunities-extension-customer-filter-label";
customerFilterLabel.textContent = "Customer";
const customerFilterButton = document.createElement("button");
customerFilterButton.id = CUSTOMER_FILTER_BUTTON_ID;
customerFilterButton.type = "button";
customerFilterButton.className = "opportunities-extension-customer-filter-trigger";
customerFilterButton.setAttribute("aria-expanded", "false");
customerFilterButton.setAttribute("aria-controls", CUSTOMER_FILTER_PANEL_ID);
customerFilterButton.addEventListener("click", toggleCustomerFilterPanel);
const customerFilterPanel = document.createElement("section");
customerFilterPanel.id = CUSTOMER_FILTER_PANEL_ID;
customerFilterPanel.className = "opportunities-extension-customer-filter-panel";
customerFilterPanel.hidden = true;
const customerSearchInput = document.createElement("input");
customerSearchInput.id = CUSTOMER_FILTER_SEARCH_ID;
customerSearchInput.className = "opportunities-extension-customer-filter-search";
customerSearchInput.type = "search";
customerSearchInput.placeholder = "Search customers";
customerSearchInput.setAttribute("aria-label", "Search customers");
customerSearchInput.addEventListener("input", () => {
opportunitiesFilterState.customerSearch = customerSearchInput.value;
saveFilterPreferences();
renderCustomerFilterList();
});
const selectAllLabel = document.createElement("label");
selectAllLabel.className = "opportunities-extension-customer-filter-select-all";
const selectAllCheckbox = document.createElement("input");
selectAllCheckbox.id = CUSTOMER_FILTER_SELECT_ALL_ID;
selectAllCheckbox.type = "checkbox";
selectAllCheckbox.addEventListener("change", () => {
const customerOptions = getCustomerOptions();
opportunitiesFilterState.selectedCustomerKeys = selectAllCheckbox.checked
? new Set(customerOptions.map((customer) => customer.key))
: new Set();
opportunitiesFilterState.hasSavedCustomerFilter = true;
saveFilterPreferences();
renderCustomerFilter();
renderOpportunitiesTable();
});
const selectAllText = document.createElement("span");
selectAllText.textContent = "Select all";
selectAllLabel.append(selectAllCheckbox, selectAllText);
const customerFilterList = document.createElement("div");
customerFilterList.id = CUSTOMER_FILTER_LIST_ID;
customerFilterList.className = "opportunities-extension-customer-filter-list";
customerFilterPanel.append(customerSearchInput, selectAllLabel, customerFilterList);
customerFilter.append(customerFilterLabel, customerFilterButton, customerFilterPanel);
const ownerFilter = document.createElement("section");
ownerFilter.className = "opportunities-extension-customer-filter";
const ownerFilterLabel = document.createElement("span");
ownerFilterLabel.className = "opportunities-extension-customer-filter-label";
ownerFilterLabel.textContent = "Owner";
const ownerFilterButton = document.createElement("button");
ownerFilterButton.id = OWNER_FILTER_BUTTON_ID;
ownerFilterButton.type = "button";
ownerFilterButton.className = "opportunities-extension-customer-filter-trigger";
ownerFilterButton.setAttribute("aria-expanded", "false");
ownerFilterButton.setAttribute("aria-controls", OWNER_FILTER_PANEL_ID);
ownerFilterButton.addEventListener("click", toggleOwnerFilterPanel);
const ownerFilterPanel = document.createElement("section");
ownerFilterPanel.id = OWNER_FILTER_PANEL_ID;
ownerFilterPanel.className = "opportunities-extension-customer-filter-panel";
ownerFilterPanel.hidden = true;
const ownerSearchInput = document.createElement("input");
ownerSearchInput.id = OWNER_FILTER_SEARCH_ID;
ownerSearchInput.className = "opportunities-extension-customer-filter-search";
ownerSearchInput.type = "search";
ownerSearchInput.placeholder = "Search owners";
ownerSearchInput.setAttribute("aria-label", "Search owners");
ownerSearchInput.addEventListener("input", () => {
opportunitiesFilterState.ownerSearch = ownerSearchInput.value;
saveFilterPreferences();
renderOwnerFilterList();
});
const ownerSelectAllLabel = document.createElement("label");
ownerSelectAllLabel.className = "opportunities-extension-customer-filter-select-all";
const ownerSelectAllCheckbox = document.createElement("input");
ownerSelectAllCheckbox.id = OWNER_FILTER_SELECT_ALL_ID;
ownerSelectAllCheckbox.type = "checkbox";
ownerSelectAllCheckbox.addEventListener("change", () => {
const ownerOptions = getOwnerOptions();
opportunitiesFilterState.selectedOwnerKeys = ownerSelectAllCheckbox.checked
? new Set(ownerOptions.map((owner) => owner.key))
: new Set();
opportunitiesFilterState.hasSavedOwnerFilter = true;
saveFilterPreferences();
renderOwnerFilter();
renderOpportunitiesTable();
});
const ownerSelectAllText = document.createElement("span");
ownerSelectAllText.textContent = "Select all";
ownerSelectAllLabel.append(ownerSelectAllCheckbox, ownerSelectAllText);
const ownerFilterList = document.createElement("div");
ownerFilterList.id = OWNER_FILTER_LIST_ID;
ownerFilterList.className = "opportunities-extension-customer-filter-list";
ownerFilterPanel.append(ownerSearchInput, ownerSelectAllLabel, ownerFilterList);
ownerFilter.append(ownerFilterLabel, ownerFilterButton, ownerFilterPanel);
const tableSearchField = document.createElement("label");
tableSearchField.className = "opportunities-extension-table-search-field";
const tableSearchLabel = document.createElement("span");
tableSearchLabel.textContent = "Search";
const tableSearchInput = document.createElement("input");
tableSearchInput.id = TABLE_SEARCH_ID;
tableSearchInput.type = "search";
tableSearchInput.placeholder = "Search opportunities";
tableSearchInput.setAttribute("aria-label", "Search opportunities");
tableSearchInput.value = opportunitiesFilterState.tableSearch;
tableSearchInput.addEventListener("input", () => {
opportunitiesFilterState.tableSearch = tableSearchInput.value;
saveFilterPreferences();
renderOpportunitiesTable();
});
tableSearchField.append(tableSearchLabel, tableSearchInput);
const statusFilter = document.createElement("section");
statusFilter.className = "opportunities-extension-status-filter";
const statusFilterLabel = document.createElement("span");
statusFilterLabel.className = "opportunities-extension-status-filter-label";
statusFilterLabel.textContent = "Status";
const statusFilters = document.createElement("div");
statusFilters.id = STATUS_FILTERS_ID;
statusFilters.className = "opportunities-extension-status-filter-controls";
statusFilters.setAttribute("role", "group");
statusFilters.setAttribute("aria-label", "Filter by status");
statusFilter.append(statusFilterLabel, statusFilters);
form.addEventListener("submit", (event) => event.preventDefault());
form.append(field, customerFilter, ownerFilter, tableSearchField, statusFilter, stageFilter);
body.append(form);
const stageDashboard = document.createElement("section");
stageDashboard.id = STAGE_DASHBOARD_ID;
stageDashboard.className = "opportunities-extension-stage-dashboard";
stageDashboard.setAttribute("aria-label", "Opportunity amount by stage");
body.append(stageDashboard);
const tableSurface = document.createElement("section");
tableSurface.className = "opportunities-extension-table-surface";
const opportunitiesTable = document.createElement("table");
opportunitiesTable.id = OPPORTUNITIES_TABLE_ID;
opportunitiesTable.className = "opportunities-extension-table";
opportunitiesTable.setAttribute("aria-label", "Opportunities");
tableSurface.append(opportunitiesTable);
body.append(tableSurface);
const debugPanel = document.createElement("aside");
debugPanel.id = DEBUG_PANEL_ID;
debugPanel.className = "opportunities-extension-debug-panel";
debugPanel.hidden = true;
body.append(debugPanel);
shell.append(header, body);
overlay.append(shell);
document.body.append(overlay);
document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler();
renderStageFilterButtons();
renderStatusFilterButtons();
renderCustomerFilter();
renderOwnerFilter();
renderOpportunitiesTable();
requestOpportunitiesForPeriod(select.value);
select.focus();
}
function updateAuthBadge(status) {
const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`);
if (!badge) {
return;
}
const badgeStatus = status === AUTH_STATUS.authenticated || status === AUTH_STATUS.unauthenticated
? status
: AUTH_STATUS.pending;
badge.setAttribute("data-auth-status", badgeStatus);
badge.className = `opportunities-extension-auth-badge opportunities-extension-auth-badge-${badgeStatus}`;
badge.textContent = badgeStatus === AUTH_STATUS.authenticated
? "Authenticated"
: badgeStatus === AUTH_STATUS.unauthenticated
? "Unauthenticated"
: "Authenticating";
}
function closeOpportunitiesModal() {
const modal = document.getElementById(MODAL_ID);
if (modal) {
closePreferencesModal();
closeAccountPlansModal();
modal.hidden = true;
}
debugState.debugSecretsVisible = false;
document.documentElement.classList.remove("opportunities-extension-scroll-lock");
}
function ensureEscapeKeyHandler() {
if (window[ESCAPE_LISTENER_KEY]) {
return;
}
window[ESCAPE_LISTENER_KEY] = true;
document.addEventListener("keydown", (event) => {
const modal = document.getElementById(MODAL_ID);
if (!modal || modal.hidden) {
return;
}
if (event.ctrlKey && event.key.toLowerCase() === "d") {
event.preventDefault();
event.stopPropagation();
toggleDebugPanel();
return;
}
if (event.key === "Escape") {
const accountPlansModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
if (accountPlansModal && !accountPlansModal.hidden) {
closeAccountPlansModal();
event.preventDefault();
return;
}
const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID);
if (preferencesModal && !preferencesModal.hidden) {
closePreferencesModal();
event.preventDefault();
return;
}
if (closeCustomerFilterPanel()) {
event.preventDefault();
return;
}
if (closeOwnerFilterPanel()) {
event.preventDefault();
return;
}
closeOpportunitiesModal();
}
});
document.addEventListener("pointerdown", (event) => {
const modal = document.getElementById(MODAL_ID);
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
const ownerPanel = document.getElementById(OWNER_FILTER_PANEL_ID);
if (!modal || modal.hidden) {
return;
}
if (panel && !panel.hidden) {
const customerFilter = panel.closest(".opportunities-extension-customer-filter");
if (customerFilter && !customerFilter.contains(event.target)) {
closeCustomerFilterPanel(false);
}
}
if (ownerPanel && !ownerPanel.hidden) {
const ownerFilter = ownerPanel.closest(".opportunities-extension-customer-filter");
if (ownerFilter && !ownerFilter.contains(event.target)) {
closeOwnerFilterPanel(false);
}
}
});
}
return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler };
}

20
src/content/styles.js Normal file
View File

@@ -0,0 +1,20 @@
function createContentStyles({
STYLE_ID,
redwoodStyles,
environment
}) {
const { document } = environment;
function ensureExtensionStyles() {
if (document.getElementById(STYLE_ID)) {
return;
}
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = redwoodStyles;
document.head.append(style);
}
return { ensureExtensionStyles };
}

File diff suppressed because it is too large Load Diff

10
src/content/svg.js Normal file
View File

@@ -0,0 +1,10 @@
function createSvgPaths({ environment }) {
const { document } = environment;
function appendPath(svg, className, d) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("class", className);
path.setAttribute("d", d);
svg.append(path);
}
return { appendPath };
}

32
src/content/theme.js Normal file
View File

@@ -0,0 +1,32 @@
function createContentTheme({
THEME_STORAGE_KEY,
createThemeIcon,
environment
}) {
const getLocalStorage = () => environment.localStorage;
function getSavedTheme() {
try {
return getLocalStorage().getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light";
} catch (error) {
return "light";
}
}
function applyTheme(modal, button, theme) {
const isDarkTheme = theme === "dark";
modal.setAttribute("data-theme", isDarkTheme ? "dark" : "light");
button.textContent = "";
button.setAttribute("aria-label", isDarkTheme ? "Ativar modo claro" : "Ativar modo escuro");
button.title = isDarkTheme ? "Light mode" : "Dark mode";
button.append(createThemeIcon(isDarkTheme ? "sun" : "moon"));
try {
getLocalStorage().setItem(THEME_STORAGE_KEY, isDarkTheme ? "dark" : "light");
} catch (error) {
// Continue with the selected theme when browser storage is unavailable.
}
}
return { getSavedTheme, applyTheme };
}

View File

@@ -0,0 +1,103 @@
function createAiSummaryContent({
aiSummaryState,
invalidateSalesCommonToken,
logRequestFailure,
logRequestStart,
logRequestSuccess,
readResponseBodyForDebug,
requestTokenRelaySalesCommon,
environment
}) {
const { document, fetch } = environment;
function requestGenerateAiSummary(optyNumber) {
const key = String(optyNumber);
const existing = aiSummaryState.aiSummariesByOptyNumber.get(key);
if (existing && existing.status === "loading") {
return existing.promise;
}
const state = { status: "loading", summaryText: "", error: "", promise: null };
aiSummaryState.aiSummariesByOptyNumber.set(key, state);
state.promise = (async () => {
let entry;
try {
let token = await requestTokenRelaySalesCommon();
if (!token) {
throw new Error("Authentication is required to generate the summary.");
}
const path = "/api/sales-common/gen-ai/v1/summarizations/generate";
const url = `https://eeho.fa.us2.oraclecloud.com${path}`;
const payload = { path, promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: key };
let response;
for (let attempt = 0; attempt < 2; attempt += 1) {
const headers = { Accept: "application/json", Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
entry = logRequestStart({
name: "requestGenerateAiSummary", method: "POST", url, headers,
body: JSON.stringify(payload, null, 2), metadata: { optyNumber: key, attempt: attempt + 1 }
});
response = await fetch(url, {
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
});
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
if (response.status !== 401 && response.status !== 403) {
break;
}
// A late failure for an old token must not discard a newly refreshed token.
invalidateSalesCommonToken(token);
if (attempt === 0) {
token = await requestTokenRelaySalesCommon();
}
}
if (!response.ok) {
throw new Error(`requestGenerateAiSummary failed with status ${response.status}.`);
}
let data;
try {
data = await response.json();
} catch (error) {
throw new Error("requestGenerateAiSummary returned an invalid JSON response.");
}
if (!data || typeof data.summaryText !== "string" || !data.summaryText.trim()) {
throw new Error("requestGenerateAiSummary returned an empty summary.");
}
state.summaryText = data.summaryText;
state.status = "ready";
} catch (error) {
state.status = "error";
state.error = error.message || "Unable to generate the AI summary.";
if (entry) {
logRequestFailure(entry, state.error);
}
} finally {
// Update only visible summaries, without reopening rows or rebuilding the table.
document.querySelectorAll(".opportunities-extension-ai-summary").forEach((alert) => {
if (alert.dataset.optyNumber === key) {
alert.replaceWith(createAiSummaryAlert(key));
}
});
}
})();
return state.promise;
}
function createAiSummaryAlert(optyNumber) {
const state = aiSummaryState.aiSummariesByOptyNumber.get(optyNumber);
const alert = document.createElement("div");
alert.className = "opportunities-extension-ai-summary";
alert.dataset.optyNumber = optyNumber;
alert.setAttribute("role", "status");
alert.setAttribute("aria-live", "polite");
alert.setAttribute("aria-busy", String(!state || state.status === "loading"));
const title = document.createElement("strong");
title.textContent = "Summary generated by AI";
const description = document.createElement("p");
description.textContent = !state || state.status === "loading"
? "Generating summary..."
: state.status === "error" ? state.error : state.summaryText;
alert.classList.toggle("opportunities-extension-ai-summary-error", Boolean(state && state.status === "error"));
alert.append(title, description);
return alert;
}
return { requestGenerateAiSummary, createAiSummaryAlert };
}

View File

@@ -0,0 +1,5 @@
function createAiSummaryState() {
return {
aiSummariesByOptyNumber: new Map()
};
}

View File

@@ -0,0 +1,383 @@
function createConsumptionChart({
CONSUMPTION_TREND_PERIODS,
CONSUMPTION_TREND_SERIES,
SVG_NAMESPACE,
calculateAverageConsumption,
consumptionTrendState,
createConsumptionTrendKey,
createNiceConsumptionMaximum,
formatCompactConsumptionNumber,
formatConsumptionCurrency,
formatConsumptionPointLabel,
formatConsumptionRange,
normalizeConsumptionTrendItems,
environment
}) {
const { document, window } = environment;
function renderConsumptionTrendChart(section, chart, items, period, subPlanNum) {
chart.textContent = "";
chart.setAttribute("aria-busy", "false");
const points = normalizeConsumptionTrendItems(items);
if (points.length === 0) {
const empty = document.createElement("p");
empty.className = "opportunities-extension-consumption-chart-empty";
empty.textContent = "No consumption data found for this period.";
chart.append(empty);
return;
}
const option = CONSUMPTION_TREND_PERIODS.find((item) => item.value === period);
const windowSize = option ? option.windowSize : points.length;
const offsetKey = createConsumptionTrendKey(subPlanNum, period);
const maxOffset = Math.max(0, Math.ceil(points.length / windowSize) - 1);
const offset = Math.min(consumptionTrendState.consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset);
const end = Math.max(0, points.length - (offset * windowSize));
const start = Math.max(0, end - windowSize);
const visiblePoints = points.slice(start, end);
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, offset);
const navigation = document.createElement("div");
navigation.className = "opportunities-extension-consumption-navigation";
const olderButton = createConsumptionNavigationButton("older", start === 0);
const newerButton = createConsumptionNavigationButton("newer", end === points.length);
const range = document.createElement("strong");
range.textContent = formatConsumptionRange(visiblePoints, period);
olderButton.addEventListener("click", () => {
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1));
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
});
newerButton.addEventListener("click", () => {
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1));
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
});
navigation.append(olderButton, range, newerButton);
const figure = document.createElement("figure");
figure.className = "opportunities-extension-consumption-figure";
const visual = document.createElement("div");
visual.className = "opportunities-extension-consumption-visual";
const tooltip = document.createElement("div");
tooltip.className = "opportunities-extension-consumption-tooltip";
tooltip.hidden = true;
tooltip.setAttribute("role", "tooltip");
const activeSeries = getConsumptionTrendSeriesFilter(subPlanNum);
const svg = createConsumptionSvg(
visiblePoints,
period,
end === points.length,
activeSeries,
tooltip
);
const legend = createConsumptionLegend(
section,
chart,
items,
period,
subPlanNum,
activeSeries
);
visual.append(svg, legend);
figure.append(visual, tooltip);
chart.append(navigation, figure);
}
function getConsumptionTrendSeriesFilter(subPlanNum) {
if (!consumptionTrendState.consumptionTrendSeriesFilters.has(subPlanNum)) {
consumptionTrendState.consumptionTrendSeriesFilters.set(
subPlanNum,
new Set(CONSUMPTION_TREND_SERIES.map((series) => series.value))
);
}
return consumptionTrendState.consumptionTrendSeriesFilters.get(subPlanNum);
}
function createConsumptionLegend(section, chart, items, period, subPlanNum, activeSeries) {
const legend = document.createElement("div");
legend.className = "opportunities-extension-consumption-legend";
legend.setAttribute("role", "group");
legend.setAttribute("aria-label", "Chart series filters");
CONSUMPTION_TREND_SERIES.forEach((series) => {
const button = document.createElement("button");
const swatch = document.createElement("span");
const label = document.createElement("span");
button.type = "button";
button.dataset.series = series.value;
button.setAttribute("aria-pressed", String(activeSeries.has(series.value)));
button.setAttribute("aria-label", `${activeSeries.has(series.value) ? "Hide" : "Show"} ${series.label}`);
swatch.className = `opportunities-extension-consumption-legend-swatch opportunities-extension-consumption-legend-${series.value}`;
swatch.setAttribute("aria-hidden", "true");
label.textContent = series.label;
button.append(swatch, label);
button.addEventListener("click", () => {
if (activeSeries.has(series.value)) {
activeSeries.delete(series.value);
} else {
activeSeries.add(series.value);
}
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
chart.querySelector(`[data-series="${series.value}"]`)?.focus();
});
legend.append(button);
});
return legend;
}
function createConsumptionNavigationButton(direction, disabled) {
const button = document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-consumption-navigation-button";
button.disabled = disabled;
button.setAttribute("aria-label", direction === "older" ? "Show older consumption data" : "Show newer consumption data");
button.title = direction === "older" ? "Older" : "Newer";
const icon = createSvgElement("svg", {
viewBox: "0 0 24 24",
"aria-hidden": "true",
focusable: "false"
});
icon.append(createSvgElement("path", {
d: direction === "older" ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7",
fill: "none",
stroke: "currentColor",
"stroke-linecap": "round",
"stroke-linejoin": "round",
"stroke-width": "2"
}));
button.append(icon);
return button;
}
function createConsumptionSvg(points, period, hasPartialLatestPoint, activeSeries, tooltip) {
const width = 1000;
const height = 320;
const plot = { left: 62, top: 24, right: 980, bottom: 258 };
const plotWidth = plot.right - plot.left;
const plotHeight = plot.bottom - plot.top;
const average = calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries);
const maximum = createNiceConsumptionMaximum(Math.max(average, ...points.map((point) => point.total)));
const svg = createSvgElement("svg", {
viewBox: `0 0 ${width} ${height}`,
role: "img",
"aria-label": `${period} cloud consumption chart`
});
svg.classList.add("opportunities-extension-consumption-svg");
for (let tick = 0; tick <= 5; tick += 1) {
const value = (maximum / 5) * tick;
const y = plot.bottom - ((value / maximum) * plotHeight);
svg.append(
createSvgElement("line", { x1: plot.left, y1: y, x2: plot.right, y2: y, class: "consumption-grid-line" }),
createSvgText(plot.left - 10, y + 4, formatCompactConsumptionNumber(value), "consumption-axis-label", "end")
);
}
const step = plotWidth / Math.max(points.length, 1);
const barWidth = Math.min(44, Math.max(10, step * 0.58));
points.forEach((point, index) => {
const x = plot.left + (step * index) + ((step - barWidth) / 2);
const fundedAmount = activeSeries.has("funded") ? point.funded : 0;
const overageAmount = activeSeries.has("overage") ? point.overage : 0;
const visibleTotal = fundedAmount + overageAmount;
const fundedHeight = (fundedAmount / maximum) * plotHeight;
const overageHeight = (overageAmount / maximum) * plotHeight;
const group = createSvgElement("g", {
class: "consumption-bar-group",
tabindex: "0",
role: "img",
"aria-label": createConsumptionPointAriaLabel(point, period)
});
["pointerenter", "pointermove", "focus"].forEach((eventName) => {
group.addEventListener(eventName, (event) => {
showConsumptionTooltip(tooltip, point, period, event);
});
});
["pointerleave", "blur"].forEach((eventName) => {
group.addEventListener(eventName, () => {
tooltip.hidden = true;
});
});
if (fundedHeight > 0) {
const fundedBar = createSvgElement("rect", {
x,
y: plot.bottom - fundedHeight,
width: barWidth,
height: fundedHeight,
class: "consumption-bar-funded"
});
appendConsumptionBarAnimation(
fundedBar,
plot.bottom,
plot.bottom - fundedHeight,
fundedHeight,
index * 34
);
group.append(fundedBar);
}
if (overageHeight > 0) {
const overageTop = plot.bottom - fundedHeight - overageHeight;
const overageBar = createSvgElement("rect", {
x,
y: overageTop,
width: barWidth,
height: overageHeight,
class: "consumption-bar-overage"
});
appendConsumptionBarAnimation(
overageBar,
plot.bottom - fundedHeight,
overageTop,
overageHeight,
(index * 34) + 90
);
group.append(overageBar);
}
const top = plot.bottom - fundedHeight - overageHeight;
if (visibleTotal > 0) {
const valueLabel = createSvgText(
x + (barWidth / 2),
Math.max(plot.top + 11, top - 7),
formatCompactConsumptionNumber(visibleTotal),
"consumption-value-label consumption-animated-label",
"middle"
);
valueLabel.style.animationDelay = `${(index * 34) + 360}ms`;
group.append(valueLabel);
}
const label = createSvgText(
x + (barWidth / 2),
plot.bottom + 18,
formatConsumptionPointLabel(point, period),
"consumption-x-label",
period === "monthly" || period === "quarterly" ? "middle" : "end"
);
if (period === "weekly" || period === "daily") {
label.setAttribute("transform", `rotate(-65 ${x + (barWidth / 2)} ${plot.bottom + 18})`);
}
group.append(label);
svg.append(group);
});
if (activeSeries.has("average")) {
const averageY = plot.bottom - ((average / maximum) * plotHeight);
const averageLine = createSvgElement("line", {
x1: plot.left,
y1: averageY,
x2: plot.right,
y2: averageY,
class: "consumption-average-line consumption-animated-average"
});
svg.append(averageLine);
}
return svg;
}
function appendConsumptionBarAnimation(element, fromY, toY, height, delayMs) {
if (prefersReducedMotion()) {
return;
}
element.append(
createSvgElement("animate", {
attributeName: "y",
from: fromY,
to: toY,
dur: "0.52s",
begin: `${delayMs}ms`,
fill: "freeze",
calcMode: "spline",
keyTimes: "0;1",
keySplines: "0.2 0.8 0.2 1"
}),
createSvgElement("animate", {
attributeName: "height",
from: "0",
to: height,
dur: "0.52s",
begin: `${delayMs}ms`,
fill: "freeze",
calcMode: "spline",
keyTimes: "0;1",
keySplines: "0.2 0.8 0.2 1"
})
);
}
function prefersReducedMotion() {
return typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function createConsumptionPointAriaLabel(point, period) {
return `${formatConsumptionPointLabel(point, period)}. `
+ `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}. `
+ `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}. `
+ `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}.`;
}
function showConsumptionTooltip(tooltip, point, period, event) {
const figure = tooltip.parentElement;
if (!figure) {
return;
}
tooltip.textContent = "";
const title = document.createElement("strong");
title.textContent = formatConsumptionPointLabel(point, period);
const details = document.createElement("span");
details.textContent = `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}`;
const overage = document.createElement("span");
overage.textContent = `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}`;
const total = document.createElement("span");
total.textContent = `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}`;
tooltip.append(title, details, overage, total);
tooltip.hidden = false;
const figureBounds = figure.getBoundingClientRect();
const targetBounds = event.currentTarget.getBoundingClientRect();
const pointerX = Number.isFinite(event.clientX) && event.clientX > 0
? event.clientX
: targetBounds.left + (targetBounds.width / 2);
const pointerY = Number.isFinite(event.clientY) && event.clientY > 0
? event.clientY
: targetBounds.top;
const left = Math.min(
Math.max(8, pointerX - figureBounds.left + 12),
Math.max(8, figureBounds.width - tooltip.offsetWidth - 8)
);
const top = Math.max(8, pointerY - figureBounds.top - tooltip.offsetHeight - 10);
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
}
function createSvgElement(name, attributes = {}) {
const element = document.createElementNS(SVG_NAMESPACE, name);
Object.entries(attributes).forEach(([key, value]) => {
element.setAttribute(key, String(value));
});
return element;
}
function createSvgText(x, y, value, className, anchor) {
const text = createSvgElement("text", { x, y, class: className, "text-anchor": anchor });
text.textContent = value;
return text;
}
return { renderConsumptionTrendChart, getConsumptionTrendSeriesFilter, createConsumptionLegend, createConsumptionNavigationButton, createConsumptionSvg, appendConsumptionBarAnimation, prefersReducedMotion, createConsumptionPointAriaLabel, showConsumptionTooltip, createSvgElement, createSvgText };
}

View File

@@ -0,0 +1,155 @@
function createConsumptionModel() {
function normalizeConsumptionTrendItems(items) {
const points = new Map();
(Array.isArray(items) ? items : []).forEach((item) => {
const dateKey = String(item.fyDate || (Array.isArray(item.fiscalTime) ? item.fiscalTime.join("|") : ""));
if (!dateKey) {
return;
}
if (!points.has(dateKey)) {
points.set(dateKey, {
dateKey,
date: parseConsumptionDate(item.fyDate),
fiscalTime: Array.isArray(item.fiscalTime) ? item.fiscalTime.slice() : [],
fiscalQtr: item.fiscalQtr || "",
funded: 0,
overage: 0,
arr: 0,
currencyCode: item.currencyCode || "USD"
});
}
const point = points.get(dateKey);
const amount = toFiniteNumber(item.usedAmount);
const category = String(item.usageCategory || "Funded Allocation").toUpperCase();
if (category === "OVERAGE") {
point.overage += amount;
} else {
point.funded += amount;
}
point.arr = Math.max(point.arr, toFiniteNumber(item.arrCd));
});
return Array.from(points.values())
.map((point) => ({ ...point, total: point.funded + point.overage }))
.sort((left, right) => left.dateKey.localeCompare(right.dateKey));
}
function getConsumptionPointTotal(point, activeSeries) {
const selectedSeries = activeSeries && typeof activeSeries.has === "function"
? activeSeries
: new Set(["funded", "overage"]);
return (selectedSeries.has("funded") ? point.funded : 0)
+ (selectedSeries.has("overage") ? point.overage : 0);
}
function calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries) {
const completePoints = hasPartialLatestPoint && points.length > 1 ? points.slice(0, -1) : points;
return completePoints.reduce(
(sum, point) => sum + getConsumptionPointTotal(point, activeSeries),
0
) / Math.max(completePoints.length, 1);
}
function createNiceConsumptionMaximum(value) {
if (!Number.isFinite(value) || value <= 0) {
return 1;
}
const magnitude = 10 ** Math.floor(Math.log10(value));
const normalized = value / magnitude;
const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return step * magnitude;
}
function formatConsumptionPointLabel(point, period) {
if (period === "quarterly") {
return point.fiscalTime[0] || point.fiscalQtr || point.dateKey;
}
if (period === "monthly") {
return point.fiscalTime[1] || formatConsumptionDate(point.date, { month: "short" });
}
return point.fiscalTime[0] || formatConsumptionDate(point.date, {
day: "2-digit",
month: "short",
year: "2-digit"
});
}
function formatConsumptionRange(points, period) {
if (points.length === 0) {
return "No data";
}
if (period === "quarterly") {
return `${formatConsumptionPointLabel(points[0], period)} - ${formatConsumptionPointLabel(points[points.length - 1], period)}`;
}
if (period === "monthly") {
const options = { month: "short", year: "numeric" };
return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`;
}
const options = { day: "2-digit", month: "short" };
return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`;
}
function parseConsumptionDate(value) {
if (!value) {
return null;
}
const parts = String(value).split("-").map(Number);
return parts.length === 3 && parts.every(Number.isFinite)
? new Date(parts[0], parts[1] - 1, parts[2])
: null;
}
function formatConsumptionDate(date, options) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return "-";
}
return new Intl.DateTimeFormat("en-US", options).format(date).replace(",", "");
}
function formatCompactConsumptionNumber(value) {
const amount = toFiniteNumber(value);
if (Math.abs(amount) >= 1000000) {
return `${(amount / 1000000).toFixed(amount >= 10000000 ? 0 : 1).replace(".0", "")}M`;
}
if (Math.abs(amount) >= 1000) {
return `${Math.round(amount / 1000)}K`;
}
return String(Math.round(amount));
}
function formatConsumptionCurrency(value, currencyCode) {
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currencyCode || "USD",
maximumFractionDigits: 0
}).format(toFiniteNumber(value));
} catch (error) {
return `${currencyCode || "USD"} ${Math.round(toFiniteNumber(value)).toLocaleString("en-US")}`;
}
}
function toFiniteNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
return { normalizeConsumptionTrendItems, getConsumptionPointTotal, calculateAverageConsumption, createNiceConsumptionMaximum, formatConsumptionPointLabel, formatConsumptionRange, parseConsumptionDate, formatConsumptionDate, formatCompactConsumptionNumber, formatConsumptionCurrency, toFiniteNumber };
}

View File

@@ -0,0 +1,93 @@
function createConsumptionPlansAction({
messageTypes,
appendBackgroundRequestLog,
consumptionPlansState,
getNestedValue,
openAccountPlansModal,
renderOpportunitiesTable,
requestConsumerTokenServiceOnce,
sendRuntimeMessage,
showCopyConfirmation,
environment
}) {
const { document } = environment;
function createCloudConsumptionButton(opportunity) {
const button = document.createElement("button");
const optyNumber = String(opportunity.OptyNumber || "");
const partyNumber = getNestedValue(opportunity, ["CustomerAccount", "PartyNumber"]);
const requestState = consumptionPlansState.accountPlansRequestState.get(optyNumber);
const isLoading = requestState && requestState.status === "loading";
button.type = "button";
button.className = "opportunities-extension-action-button";
button.setAttribute("aria-label", `View cloud consumption for ${optyNumber || "opportunity"}`);
button.title = isLoading ? "Loading customer consumption" : "View customer consumption";
button.disabled = Boolean(isLoading);
button.classList.toggle("opportunities-extension-action-button-loading", Boolean(isLoading));
button.append(createCloudConsumptionIcon());
button.addEventListener("click", () => {
if (!partyNumber) {
showCopyConfirmation("Customer Party Number is unavailable.", true);
return;
}
consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "loading" });
openAccountPlansModal(opportunity, [], "loading");
renderOpportunitiesTable();
requestConsumerTokenServiceOnce()
.then((authorizations) => sendRuntimeMessage({
type: messageTypes.requestAccountPlans,
partyNumber: String(partyNumber),
authorizations
}))
.then((response) => {
appendBackgroundRequestLog(response && response.debugRequests);
if (!response || !response.ok) {
throw new Error(response && response.error ? response.error : "Unable to load cloud consumption.");
}
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
status: "ready",
plans: Array.isArray(response.plans) ? response.plans : []
});
renderOpportunitiesTable();
openAccountPlansModal(opportunity, response.plans || [], "ready");
}).catch((error) => {
const errorMessage = error.message || "Unable to load cloud consumption.";
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
status: "error",
error: errorMessage
});
renderOpportunitiesTable();
openAccountPlansModal(opportunity, [], "error", errorMessage);
showCopyConfirmation(errorMessage, true);
});
});
return button;
}
function createCloudConsumptionIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const cloud = document.createElementNS("http://www.w3.org/2000/svg", "path");
const usage = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
cloud.setAttribute("d", "M7.2 18.5h10.1a4.2 4.2 0 0 0 .5-8.37A6.25 6.25 0 0 0 5.9 8.75a4.9 4.9 0 0 0 1.3 9.75Z");
usage.setAttribute("d", "M9 15v-2m3 2v-4m3 4V9");
[cloud, usage].forEach((path) => {
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
path.setAttribute("stroke-width", "1.7");
});
svg.append(cloud, usage);
return svg;
}
return { createCloudConsumptionButton, createCloudConsumptionIcon };
}

View File

@@ -0,0 +1,5 @@
function createConsumptionPlansState() {
return {
accountPlansRequestState: new Map()
};
}

View File

@@ -0,0 +1,358 @@
function createConsumptionPlansView({
ACCOUNT_PLANS_MODAL_ID,
DEFAULT_CONSUMPTION_TREND_PERIOD,
MODAL_ID,
createCloseIcon,
createConsumptionTrendSection,
createSvgElement,
formatOracleResponseDate,
getNestedValue,
loadConsumptionTrendForPanel,
environment
}) {
const { document, requestAnimationFrame, setTimeout, window } = environment;
function openAccountPlansModal(opportunity, plans, state = "ready", errorMessage = "") {
const overlay = document.getElementById(MODAL_ID);
const optyNumber = String(opportunity.OptyNumber || "");
const existingModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
if (!overlay) {
return;
}
if (state !== "loading" && (
!existingModal
|| existingModal.hidden
|| existingModal.dataset.optyNumber !== optyNumber
)) {
return;
}
existingModal?.remove();
const modal = document.createElement("section");
modal.id = ACCOUNT_PLANS_MODAL_ID;
modal.className = "opportunities-extension-account-plans-modal";
modal.dataset.optyNumber = optyNumber;
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-account-plans-title");
modal.setAttribute("aria-busy", String(state === "loading"));
const dialog = document.createElement("div");
dialog.className = "opportunities-extension-account-plans-dialog";
const header = document.createElement("header");
header.className = "opportunities-extension-account-plans-header";
const heading = document.createElement("div");
const title = document.createElement("h2");
title.id = "opportunities-extension-account-plans-title";
title.textContent = "Cloud Consumption";
const subtitle = document.createElement("p");
subtitle.textContent = getNestedValue(opportunity, ["CustomerAccount", "PartyUniqueName"]) || opportunity.Name || opportunity.OptyNumber || "Opportunity";
heading.append(title, subtitle);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Close cloud consumption");
closeButton.title = "Close";
closeButton.append(createCloseIcon());
closeButton.addEventListener("click", closeAccountPlansModal);
header.append(heading, closeButton);
const content = document.createElement("div");
content.className = "opportunities-extension-account-plans-content";
if (state === "loading") {
content.append(createAccountPlansSkeleton());
} else if (state === "error") {
const error = document.createElement("p");
error.className = "opportunities-extension-account-plans-error";
error.setAttribute("role", "alert");
error.textContent = errorMessage || "Unable to load cloud consumption.";
content.append(error);
} else if (!Array.isArray(plans) || plans.length === 0) {
const empty = document.createElement("p");
empty.className = "opportunities-extension-account-plans-empty";
empty.textContent = "No account plans found for this customer.";
content.append(empty);
} else {
const tabList = document.createElement("div");
tabList.className = "opportunities-extension-account-plans-tabs";
tabList.setAttribute("role", "tablist");
tabList.setAttribute("aria-label", "Account plans");
const tabsNavigation = createAccountPlansTabsNavigation(tabList);
const panels = document.createElement("div");
panels.className = "opportunities-extension-account-plans-panels";
plans.forEach((plan, index) => {
const tabId = `opportunities-extension-account-plan-tab-${index}`;
const panelId = `opportunities-extension-account-plan-panel-${index}`;
const tab = document.createElement("button");
tab.id = tabId;
tab.type = "button";
tab.className = "opportunities-extension-account-plan-tab";
tab.setAttribute("role", "tab");
tab.setAttribute("aria-controls", panelId);
tab.setAttribute("aria-selected", String(index === 0));
tab.tabIndex = index === 0 ? 0 : -1;
const planLabel = [plan.planType, plan.subPlanNum]
.filter((value) => value !== null && value !== undefined && value !== "")
.join(" ") || `Plan ${index + 1}`;
const headerStatus = plan.hdrStatusCode === null || plan.hdrStatusCode === undefined || plan.hdrStatusCode === ""
? "Unknown"
: String(plan.hdrStatusCode);
tab.setAttribute("aria-label", `${planLabel}. Header Status: ${headerStatus}`);
tab.append(createAccountPlanStatusIndicator(headerStatus), document.createTextNode(planLabel));
const panel = document.createElement("section");
panel.id = panelId;
panel.className = "opportunities-extension-account-plan-panel";
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", tabId);
panel.hidden = index !== 0;
panel.append(createAccountPlanDetails(plan), createConsumptionTrendSection(plan));
tab.addEventListener("click", () => {
tabList.querySelectorAll("[role='tab']").forEach((candidate) => {
const isSelected = candidate === tab;
candidate.setAttribute("aria-selected", String(isSelected));
candidate.tabIndex = isSelected ? 0 : -1;
});
panels.querySelectorAll("[role='tabpanel']").forEach((candidate) => {
candidate.hidden = candidate !== panel;
});
const trendSection = panel.querySelector(".opportunities-extension-consumption-trend");
loadConsumptionTrendForPanel(
panel,
plan,
trendSection ? trendSection.dataset.period : DEFAULT_CONSUMPTION_TREND_PERIOD
);
});
tabList.append(tab);
panels.append(panel);
});
content.append(tabsNavigation, panels);
}
dialog.append(header, content);
modal.append(dialog);
overlay.append(modal);
modal.querySelector("[role='tab'], .opportunities-extension-icon-button")?.focus();
if (state === "ready" && Array.isArray(plans) && plans.length > 0) {
const firstPanel = modal.querySelector("[role='tabpanel']");
if (firstPanel) {
loadConsumptionTrendForPanel(firstPanel, plans[0], DEFAULT_CONSUMPTION_TREND_PERIOD);
}
}
}
function createAccountPlansTabsNavigation(tabList) {
const navigation = document.createElement("div");
navigation.className = "opportunities-extension-account-plans-tabs-navigation";
const previousButton = createAccountPlansTabNavigationButton("previous");
const nextButton = createAccountPlansTabNavigationButton("next");
const updateNavigation = () => {
const maximumScroll = Math.max(0, tabList.scrollWidth - tabList.clientWidth);
const tabs = Array.from(tabList.querySelectorAll("[role='tab']"));
const viewportStart = tabList.scrollLeft;
const viewportEnd = viewportStart + tabList.clientWidth;
const hasContentBefore = tabList.scrollLeft > 1
|| tabs.some((tab) => tab.offsetLeft < viewportStart - 1);
const hasContentAfter = tabList.scrollLeft < maximumScroll - 1
|| tabs.some((tab) => (tab.offsetLeft + tab.offsetWidth) > viewportEnd + 1);
previousButton.hidden = !hasContentBefore;
nextButton.hidden = !hasContentAfter;
};
const scrollTabs = (direction) => {
tabList.scrollBy({
left: direction * Math.max(160, Math.floor(tabList.clientWidth * .72)),
behavior: "smooth"
});
};
previousButton.addEventListener("click", () => scrollTabs(-1));
nextButton.addEventListener("click", () => scrollTabs(1));
tabList.addEventListener("scroll", updateNavigation, { passive: true });
const handleResize = () => {
if (!navigation.isConnected) {
window.removeEventListener("resize", handleResize);
return;
}
updateNavigation();
};
window.addEventListener("resize", handleResize, { passive: true });
requestAnimationFrame(() => requestAnimationFrame(updateNavigation));
setTimeout(updateNavigation, 0);
navigation.append(previousButton, tabList, nextButton);
return navigation;
}
function createAccountPlansTabNavigationButton(direction) {
const isPrevious = direction === "previous";
const button = document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-account-plans-tab-navigation-button";
button.setAttribute("aria-label", isPrevious ? "Show previous account plans" : "Show next account plans");
button.title = isPrevious ? "Previous plans" : "Next plans";
const icon = createSvgElement("svg", {
viewBox: "0 0 24 24",
"aria-hidden": "true",
focusable: "false"
});
icon.append(createSvgElement("path", {
d: isPrevious ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7",
fill: "none",
stroke: "currentColor",
"stroke-linecap": "round",
"stroke-linejoin": "round",
"stroke-width": "2"
}));
button.append(icon);
return button;
}
function createAccountPlansSkeleton() {
const skeleton = document.createElement("div");
skeleton.className = "opportunities-extension-account-plans-skeleton";
skeleton.setAttribute("role", "status");
const announcement = document.createElement("span");
announcement.className = "opportunities-extension-visually-hidden";
announcement.textContent = "Loading cloud consumption";
const tabs = document.createElement("div");
tabs.className = "opportunities-extension-account-plans-skeleton-tabs";
const tab = document.createElement("span");
tab.className = "opportunities-extension-account-plans-skeleton-tab";
const status = document.createElement("span");
status.className = "opportunities-extension-account-plans-skeleton-status";
const tabLabel = document.createElement("span");
tabLabel.className = "opportunities-extension-account-plans-skeleton-bar";
tabLabel.style.width = "104px";
tab.append(status, tabLabel);
tabs.append(tab);
const details = document.createElement("div");
details.className = "opportunities-extension-account-plans-skeleton-details";
[72, 88, 64, 78, 58, 68].forEach((width) => {
const row = document.createElement("div");
const label = document.createElement("span");
const value = document.createElement("span");
label.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-label";
value.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-value";
value.style.width = `${width}%`;
row.append(label, value);
details.append(row);
});
const trend = document.createElement("div");
trend.className = "opportunities-extension-account-plans-skeleton-trend";
const trendHeader = document.createElement("div");
trendHeader.className = "opportunities-extension-account-plans-skeleton-trend-header";
const periods = document.createElement("div");
periods.className = "opportunities-extension-account-plans-skeleton-periods";
[58, 52, 56, 38].forEach((width, index) => {
const period = document.createElement("span");
period.className = "opportunities-extension-account-plans-skeleton-bar";
period.style.width = `${width}px`;
if (index === 2) period.classList.add("opportunities-extension-account-plans-skeleton-period-active");
periods.append(period);
});
trendHeader.append(periods);
const navigation = document.createElement("div");
navigation.className = "opportunities-extension-account-plans-skeleton-navigation";
[32, 124, 32].forEach((width) => {
const item = document.createElement("span");
item.className = "opportunities-extension-account-plans-skeleton-bar";
item.style.width = `${width}px`;
navigation.append(item);
});
const chart = document.createElement("div");
chart.className = "opportunities-extension-account-plans-skeleton-chart";
const plot = document.createElement("div");
plot.className = "opportunities-extension-account-plans-skeleton-plot";
[34, 42, 38, 48, 44, 58, 64, 55, 76, 68, 86, 62].forEach((height) => {
const bar = document.createElement("span");
bar.className = "opportunities-extension-account-plans-skeleton-bar";
bar.style.height = `${height}%`;
plot.append(bar);
});
const legend = document.createElement("div");
legend.className = "opportunities-extension-account-plans-skeleton-legend";
[112, 68, 132].forEach((width) => {
const item = document.createElement("span");
item.className = "opportunities-extension-account-plans-skeleton-bar";
item.style.width = `${width}px`;
legend.append(item);
});
chart.append(plot, legend);
trend.append(trendHeader, navigation, chart);
const panel = document.createElement("div");
panel.className = "opportunities-extension-account-plan-panel opportunities-extension-account-plans-skeleton-panel";
panel.append(details, trend);
skeleton.append(announcement, tabs, panel);
return skeleton;
}
function createAccountPlanStatusIndicator(statusValue) {
const indicator = document.createElement("span");
const isActive = String(statusValue).toUpperCase() === "ACTIVE";
indicator.className = "opportunities-extension-account-plan-status-indicator";
indicator.dataset.active = String(isActive);
indicator.setAttribute("aria-hidden", "true");
indicator.title = `Header Status: ${statusValue}`;
return indicator;
}
function createAccountPlanDetails(plan) {
const details = document.createElement("dl");
details.className = "opportunities-extension-account-plan-details";
const fields = [
{ label: "Booking Customer", value: plan.bookingCustomer },
{ label: "Sold To Party Name", value: plan.soldToPartyName },
{ label: "Contract ID", value: plan.contractId },
{ label: "Header Status", value: plan.hdrStatusCode },
{ label: "Start Date", value: formatOracleResponseDate(plan.startDate) },
{ label: "End Date", value: formatOracleResponseDate(plan.endDate) }
];
fields.forEach((field) => {
const group = document.createElement("div");
const term = document.createElement("dt");
const description = document.createElement("dd");
term.textContent = field.label;
description.textContent = field.value === null || field.value === undefined || field.value === "" ? "-" : String(field.value);
group.append(term, description);
details.append(group);
});
return details;
}
function closeAccountPlansModal() {
const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
if (modal) {
modal.hidden = true;
}
}
return { openAccountPlansModal, createAccountPlansTabsNavigation, createAccountPlansTabNavigationButton, createAccountPlansSkeleton, createAccountPlanStatusIndicator, createAccountPlanDetails, closeAccountPlansModal };
}

View File

@@ -0,0 +1,171 @@
function createConsumptionTrendController({
messageTypes,
CONSUMPTION_TREND_PERIODS,
DEFAULT_CONSUMPTION_TREND_PERIOD,
appendBackgroundRequestLog,
consumptionTrendState,
renderConsumptionTrendChart,
requestConsumerTokenServiceOnce,
sendRuntimeMessage,
environment
}) {
const { document } = environment;
function createConsumptionTrendSection(plan) {
const section = document.createElement("section");
section.className = "opportunities-extension-consumption-trend";
section.dataset.subPlanNum = String(plan.subPlanNum || "");
section.dataset.period = DEFAULT_CONSUMPTION_TREND_PERIOD;
const header = document.createElement("header");
header.className = "opportunities-extension-consumption-trend-header";
const periodControls = document.createElement("div");
periodControls.className = "opportunities-extension-consumption-periods";
periodControls.setAttribute("role", "group");
periodControls.setAttribute("aria-label", "Consumption period");
CONSUMPTION_TREND_PERIODS.forEach((periodOption) => {
const button = document.createElement("button");
button.type = "button";
button.dataset.period = periodOption.value;
button.setAttribute("aria-pressed", String(periodOption.value === DEFAULT_CONSUMPTION_TREND_PERIOD));
button.textContent = periodOption.label;
button.addEventListener("click", () => {
section.dataset.period = periodOption.value;
consumptionTrendState.consumptionTrendWindowOffsets.set(
createConsumptionTrendKey(section.dataset.subPlanNum, periodOption.value),
0
);
loadConsumptionTrendForPanel(section.closest("[role='tabpanel']"), plan, periodOption.value);
});
periodControls.append(button);
});
const chart = document.createElement("div");
chart.className = "opportunities-extension-consumption-chart";
chart.setAttribute("aria-live", "polite");
header.append(periodControls);
section.append(header, chart);
return section;
}
function loadConsumptionTrendForPanel(panel, plan, period) {
if (!panel) {
return;
}
const section = panel.querySelector(".opportunities-extension-consumption-trend");
if (!section) {
return;
}
const subPlanNum = String(plan.subPlanNum || "").trim();
const selectedPeriod = CONSUMPTION_TREND_PERIODS.some((item) => item.value === period)
? period
: DEFAULT_CONSUMPTION_TREND_PERIOD;
const key = createConsumptionTrendKey(subPlanNum, selectedPeriod);
const chart = section.querySelector(".opportunities-extension-consumption-chart");
section.dataset.period = selectedPeriod;
chart.dataset.requestKey = key;
updateConsumptionPeriodButtons(section, selectedPeriod);
if (!subPlanNum) {
renderConsumptionTrendError(chart, "Sub plan number is unavailable.");
return;
}
const cached = consumptionTrendState.consumptionTrendCache.get(key);
renderConsumptionTrendSkeleton(chart);
if (cached && cached.status === "loading") {
cached.promise
.then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key))
.catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key));
return;
}
const request = requestConsumerTokenServiceOnce()
.then((authorizations) => sendRuntimeMessage({
type: messageTypes.requestConsumptionTrend,
subPlanNum,
period: selectedPeriod,
authorization: authorizations && authorizations.accountPlans
}))
.then((response) => {
appendBackgroundRequestLog(response && response.debugRequests);
if (!response || !response.ok) {
throw new Error(response && response.error ? response.error : "Unable to load consumption trend.");
}
return Array.isArray(response.items) ? response.items : [];
})
.finally(() => {
if (consumptionTrendState.consumptionTrendCache.get(key)?.promise === request) {
consumptionTrendState.consumptionTrendCache.delete(key);
}
});
consumptionTrendState.consumptionTrendCache.set(key, { status: "loading", promise: request });
request
.then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key))
.catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key));
}
function createConsumptionTrendKey(subPlanNum, period) {
return `${subPlanNum}:${period}`;
}
function updateConsumptionPeriodButtons(section, period) {
section.querySelectorAll(".opportunities-extension-consumption-periods button").forEach((button) => {
button.setAttribute("aria-pressed", String(button.dataset.period === period));
});
}
function renderConsumptionTrendIfCurrent(section, chart, items, period, subPlanNum, key) {
if (chart.dataset.requestKey === key) {
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
}
}
function renderConsumptionTrendErrorIfCurrent(chart, errorMessage, key) {
if (chart.dataset.requestKey === key) {
renderConsumptionTrendError(chart, errorMessage || "Unable to load consumption trend.");
}
}
function renderConsumptionTrendSkeleton(chart) {
chart.textContent = "";
chart.setAttribute("aria-busy", "true");
const skeleton = document.createElement("div");
skeleton.className = "opportunities-extension-consumption-chart-skeleton";
const announcement = document.createElement("span");
announcement.className = "opportunities-extension-visually-hidden";
announcement.textContent = "Loading consumption trend";
const plot = document.createElement("div");
plot.className = "opportunities-extension-consumption-chart-skeleton-plot";
for (let index = 0; index < 13; index += 1) {
const bar = document.createElement("span");
bar.style.height = `${28 + ((index * 17) % 58)}%`;
plot.append(bar);
}
skeleton.append(announcement, plot);
chart.append(skeleton);
}
function renderConsumptionTrendError(chart, errorMessage) {
chart.textContent = "";
chart.setAttribute("aria-busy", "false");
const error = document.createElement("p");
error.className = "opportunities-extension-consumption-chart-error";
error.setAttribute("role", "alert");
error.textContent = errorMessage;
chart.append(error);
}
return { createConsumptionTrendSection, loadConsumptionTrendForPanel, createConsumptionTrendKey, updateConsumptionPeriodButtons, renderConsumptionTrendIfCurrent, renderConsumptionTrendErrorIfCurrent, renderConsumptionTrendSkeleton, renderConsumptionTrendError };
}

View File

@@ -0,0 +1,7 @@
function createConsumptionTrendState() {
return {
consumptionTrendCache: new Map(),
consumptionTrendWindowOffsets: new Map(),
consumptionTrendSeriesFilters: new Map()
};
}

View File

@@ -0,0 +1,198 @@
function createDebugContentLog({
DEBUG_BODY_LIMIT,
debugState,
renderDebugPanel
}) {
function logRequestStart(details) {
const entry = {
id: debugState.requestLog.length + 1,
name: details.name,
method: details.method,
url: details.url,
headers: details.headers,
status: "pending",
startedAt: new Date(),
completedAt: null,
durationMs: null,
httpStatus: null,
statusText: "",
requestHeaders: details.headers,
requestBody: details.body || "",
metadata: details.metadata || {},
responseHeaders: {},
responseBody: "",
responseBodyTruncated: false,
error: ""
};
debugState.requestLog.push(entry);
renderDebugPanel();
return entry;
}
function logRequestSuccess(entry, response, responseBody) {
entry.status = response.ok ? "success" : "failed";
entry.completedAt = new Date();
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
entry.httpStatus = response.status;
entry.statusText = response.statusText || "";
entry.responseHeaders = headersToObject(response.headers);
entry.responseBody = responseBody.value;
entry.responseBodyTruncated = responseBody.truncated;
renderDebugPanel();
}
function logRequestFailure(entry, errorMessage) {
entry.status = "failed";
entry.completedAt = new Date();
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
entry.error = errorMessage;
renderDebugPanel();
}
function appendBackgroundRequestLog(entries) {
if (!Array.isArray(entries) || entries.length === 0) {
return;
}
entries.forEach((entry) => {
debugState.requestLog.push({
id: debugState.requestLog.length + 1,
name: entry.name || "backgroundRequest",
method: entry.method || "GET",
url: entry.url || "",
headers: entry.requestHeaders || {},
status: entry.status || "failed",
startedAt: new Date(entry.startedAt),
completedAt: entry.completedAt ? new Date(entry.completedAt) : null,
durationMs: entry.durationMs,
httpStatus: entry.httpStatus,
statusText: entry.statusText || "",
requestHeaders: entry.requestHeaders || {},
requestBody: entry.requestBody || "",
metadata: entry.metadata || { executionContext: "background" },
responseHeaders: entry.responseHeaders || {},
responseBody: entry.responseBody || "",
responseBodyTruncated: Boolean(entry.responseBodyTruncated),
error: entry.error || ""
});
});
renderDebugPanel();
}
async function readResponseBodyForDebug(response) {
try {
const text = await response.clone().text();
return createDebugBody(text);
} catch (error) {
return {
value: `Unable to read response body: ${error.message || "unknown error"}`,
truncated: false
};
}
}
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 headersToObject(headers) {
const headerMap = {};
headers.forEach((value, key) => {
headerMap[key] = value;
});
return headerMap;
}
function formatHeaderBlock(headers) {
const entries = Object.entries(headers);
if (entries.length === 0) {
return "(none)";
}
return entries
.map(([key, value]) => `${key}: ${debugState.debugSecretsVisible || !isSensitiveDebugKey(key)
? value
: formatHiddenDebugValue(value)}`)
.join("\n");
}
function formatDebugBody(body) {
const text = body || "(none)";
if (debugState.debugSecretsVisible) {
return text;
}
try {
return JSON.stringify(maskSensitiveDebugValue(JSON.parse(text)), null, 2);
} catch (error) {
return text;
}
}
function maskSensitiveDebugValue(value, parentKey = "") {
if (Array.isArray(value)) {
return value.map((item) => maskSensitiveDebugValue(item, parentKey));
}
if (!value || typeof value !== "object") {
return value;
}
return Object.fromEntries(Object.entries(value).map(([key, child]) => {
const isSensitive = isSensitiveDebugKey(key)
|| (key === "value" && /uiaasHeader|uiassHeader/i.test(parentKey));
return [key, isSensitive
? formatHiddenDebugValue(child)
: maskSensitiveDebugValue(child, key)];
}));
}
function isSensitiveDebugKey(key) {
return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$/i.test(key);
}
function formatHiddenDebugValue(value) {
if (value === null || value === undefined || value === "") {
return "(missing)";
}
return `(present, ${String(value).length} characters)`;
}
function formatMetadataBlock(metadata) {
const entries = Object.entries(metadata);
if (entries.length === 0) {
return "(none)";
}
return entries
.map(([key, value]) => `${key}: ${value || "(none)"}`)
.join("\n");
}
function maskToken(token) {
if (token.length <= 10) {
return "(present)";
}
return `${token.slice(0, 4)}...${token.slice(-4)}`;
}
return { logRequestStart, logRequestSuccess, logRequestFailure, appendBackgroundRequestLog, readResponseBodyForDebug, createDebugBody, headersToObject, formatHeaderBlock, formatDebugBody, maskSensitiveDebugValue, isSensitiveDebugKey, formatHiddenDebugValue, formatMetadataBlock, maskToken };
}

View File

@@ -0,0 +1,174 @@
function createDebugContentView({
DEBUG_PANEL_ID,
MODAL_ID,
debugState,
formatDebugBody,
formatHeaderBlock,
formatMetadataBlock,
environment
}) {
const { document } = environment;
function toggleDebugPanel() {
const modal = document.getElementById(MODAL_ID);
if (!modal || modal.hidden) {
return;
}
const panel = document.getElementById(DEBUG_PANEL_ID);
if (!panel) {
return;
}
panel.hidden = !panel.hidden;
renderDebugPanel();
}
function renderDebugPanel() {
const panel = document.getElementById(DEBUG_PANEL_ID);
if (!panel || panel.hidden) {
return;
}
panel.textContent = "";
const panelHeader = document.createElement("header");
panelHeader.className = "opportunities-extension-debug-panel-header";
const heading = document.createElement("div");
const title = document.createElement("h2");
title.textContent = "Debug Requests";
const summary = document.createElement("p");
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`;
heading.append(title, summary);
const visibilityButton = document.createElement("button");
visibilityButton.type = "button";
visibilityButton.className = "opportunities-extension-icon-button opportunities-extension-debug-visibility-button";
visibilityButton.setAttribute("aria-pressed", String(debugState.debugSecretsVisible));
visibilityButton.setAttribute("aria-label", debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values");
visibilityButton.title = debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values";
visibilityButton.append(createDebugVisibilityIcon(debugState.debugSecretsVisible));
visibilityButton.addEventListener("click", () => {
debugState.debugSecretsVisible = !debugState.debugSecretsVisible;
renderDebugPanel();
});
panelHeader.append(heading, visibilityButton);
const list = document.createElement("div");
list.className = "opportunities-extension-debug-list";
if (debugState.requestLog.length === 0) {
const empty = document.createElement("div");
empty.className = "opportunities-extension-debug-empty";
empty.textContent = "No requests recorded yet.";
list.append(empty);
}
debugState.requestLog.forEach((entry) => {
const item = document.createElement("details");
item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`;
const heading = document.createElement("summary");
heading.className = "opportunities-extension-debug-heading";
const name = document.createElement("strong");
name.textContent = `${entry.id}. ${entry.name}`;
const httpStatus = document.createElement("span");
httpStatus.className = "opportunities-extension-debug-http-status";
httpStatus.textContent = entry.httpStatus ? String(entry.httpStatus) : "pending";
const status = document.createElement("span");
status.className = "opportunities-extension-debug-status";
status.textContent = entry.status;
const summaryMeta = document.createElement("span");
summaryMeta.className = "opportunities-extension-debug-summary-meta";
summaryMeta.append(httpStatus, status);
heading.append(name, summaryMeta);
const lines = [
`${entry.method} ${entry.url}`,
`Started: ${formatDebugTime(entry.startedAt)}`,
entry.completedAt ? `Completed: ${formatDebugTime(entry.completedAt)} (${entry.durationMs}ms)` : "Completed: pending",
entry.httpStatus ? `HTTP: ${entry.httpStatus} ${entry.statusText}`.trim() : "",
entry.error ? `Error: ${entry.error}` : "",
`Response body truncated: ${entry.responseBodyTruncated ? "yes" : "no"}`
].filter(Boolean);
const details = document.createElement("div");
details.className = "opportunities-extension-debug-details";
appendDebugBlock(details, "Request", lines.join("\n"));
appendDebugBlock(details, "Request Metadata", formatMetadataBlock(entry.metadata));
appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders));
appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody));
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody));
item.append(heading, details);
list.append(item);
});
panel.append(panelHeader, list);
}
function createDebugVisibilityIcon(secretsVisible) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const eye = document.createElementNS("http://www.w3.org/2000/svg", "path");
const pupil = document.createElementNS("http://www.w3.org/2000/svg", "circle");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
eye.setAttribute("d", "M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12Z");
pupil.setAttribute("cx", "12");
pupil.setAttribute("cy", "12");
pupil.setAttribute("r", "2.75");
[eye, pupil].forEach((shape) => {
shape.setAttribute("fill", "none");
shape.setAttribute("stroke", "currentColor");
shape.setAttribute("stroke-linecap", "round");
shape.setAttribute("stroke-linejoin", "round");
shape.setAttribute("stroke-width", "1.7");
});
svg.append(eye, pupil);
if (secretsVisible) {
const slash = document.createElementNS("http://www.w3.org/2000/svg", "path");
slash.setAttribute("d", "M4 4l16 16");
slash.setAttribute("fill", "none");
slash.setAttribute("stroke", "currentColor");
slash.setAttribute("stroke-linecap", "round");
slash.setAttribute("stroke-width", "1.9");
svg.append(slash);
}
return svg;
}
function appendDebugBlock(container, label, value) {
const block = document.createElement("section");
const heading = document.createElement("h3");
const content = document.createElement("pre");
heading.textContent = label;
content.textContent = value;
block.append(heading, content);
container.append(block);
}
function formatDebugTime(date) {
return date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
return { toggleDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime };
}

View File

@@ -0,0 +1,6 @@
function createDebugState() {
return {
requestLog: [],
debugSecretsVisible: false
};
}

View File

@@ -0,0 +1,15 @@
function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, formatWinProbability, getNestedValue, getOwnerName }) {
return [
{ key: "name", label: "Name", value: (item) => item.Name, display: (item) => item.Name },
{ key: "optyNumber", label: "Opty Number", value: (item) => item.OptyNumber, display: (item) => item.OptyNumber },
{ key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]) },
{ key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName },
{ key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue },
{ key: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) },
{ key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode },
{ key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
{ key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c },
{ key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) },
{ key: "actions", label: "Actions", value: () => "", display: () => "", sortable: false }
];
}

View File

@@ -0,0 +1,146 @@
function createOpportunitiesController({
STAGE_OPTIONS,
STATUS_OPTIONS,
authSessionState,
consumptionPlansState,
consumptionTrendState,
getFiscalPeriodRange,
opportunitiesDashboardState,
opportunitiesFilterState,
opportunitiesQueryState,
opportunitiesTableState,
preferencesState,
productsContentState,
renderProductsProgress,
requestOpportunitiesPage,
requestOpptyProductsInBackground,
requestTokenRelayOnce,
setOpportunitiesTableState
}) {
async function requestOpportunitiesForPeriod(period) {
const dateRange = getFiscalPeriodRange(period, new Date());
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
if (preferencesState.selectedOpportunityTypeValues.size === 0) {
setOpportunitiesTableState({
items: [],
status: "empty",
message: "Select at least one Opportunity Type View in Preferences."
});
return;
}
if (!dateRange) {
setOpportunitiesTableState({
items: [],
status: "empty",
message: "No opportunities to display for this period."
});
return;
}
try {
const token = authSessionState.accessToken || await requestTokenRelayOnce();
if (requestVersion !== opportunitiesQueryState.periodRequestVersion) {
return;
}
await requestOpportunities(token, period, dateRange, requestVersion);
} catch (error) {
// Authentication and request errors are recorded by their respective request handlers.
}
}
async function requestOpportunities(token, period, dateRange, requestVersion) {
if (isCurrentPeriodRequest(requestVersion)) {
productsContentState.currentProductsRequestId = "";
productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsProgress = {
state: "idle",
completed: 0,
total: 0,
failed: 0,
cacheHits: 0
};
productsContentState.productsLastCachedAt = 0;
consumptionPlansState.accountPlansRequestState = new Map();
consumptionTrendState.consumptionTrendCache = new Map();
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
renderProductsProgress();
setOpportunitiesTableState({
items: [],
status: "loading",
message: "Loading opportunities..."
});
}
try {
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);
if (allItems && isCurrentPeriodRequest(requestVersion)) {
setOpportunitiesTableState({
items: allItems,
status: "ready",
message: ""
});
requestOpptyProductsInBackground(allItems, token);
}
} catch (error) {
if (isCurrentPeriodRequest(requestVersion)) {
setOpportunitiesTableState({
items: [],
status: "error",
message: "Unable to load opportunities."
});
}
throw error;
}
}
function resetOpportunitiesTable() {
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);
opportunitiesFilterState.selectedCustomerKeys = new Set();
opportunitiesFilterState.customerSearch = "";
opportunitiesFilterState.selectedOwnerKeys = new Set();
opportunitiesFilterState.ownerSearch = "";
opportunitiesFilterState.tableSearch = "";
opportunitiesFilterState.hasSavedCustomerFilter = false;
opportunitiesFilterState.hasSavedOwnerFilter = false;
opportunitiesFilterState.customerFilterInitialized = false;
opportunitiesFilterState.ownerFilterInitialized = false;
productsContentState.currentProductsRequestId = "";
productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsProgress = {
state: "idle",
completed: 0,
total: 0,
failed: 0,
cacheHits: 0
};
productsContentState.productsLastCachedAt = 0;
consumptionPlansState.accountPlansRequestState = new Map();
consumptionTrendState.consumptionTrendCache = new Map();
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
opportunitiesDashboardState.stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]);
opportunitiesTableState.opportunitiesTableState = {
items: [],
status: "idle",
message: "",
sortKey: "",
sortDirection: "ascending"
};
}
function isCurrentPeriodRequest(requestVersion) {
return requestVersion === opportunitiesQueryState.periodRequestVersion;
}
return { requestOpportunitiesForPeriod, requestOpportunities, resetOpportunitiesTable, isCurrentPeriodRequest };
}

View File

@@ -0,0 +1,5 @@
function createOpportunitiesDashboardState({ STAGE_OPTIONS } = {}) {
return {
stageDashboardAmounts: new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])])
};
}

View File

@@ -0,0 +1,119 @@
function createOpportunitiesDashboard({
STAGE_DASHBOARD_ID,
STAGE_OPTIONS,
getNestedValue,
opportunitiesDashboardState,
environment
}) {
const { document, requestAnimationFrame, performance } = environment;
function renderStageDashboard(items) {
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
if (!dashboard) {
return;
}
dashboard.textContent = "";
const totalCard = document.createElement("section");
const totalLabel = document.createElement("span");
const totalValue = document.createElement("strong");
totalCard.className = "opportunities-extension-stage-dashboard-card opportunities-extension-total-dashboard-card";
totalCard.setAttribute("data-stage", "TOTAL");
totalLabel.textContent = "Total Opportunities";
totalValue.className = "opportunities-extension-stage-dashboard-value";
totalValue.setAttribute("aria-label", "Total opportunities");
animateStageDashboardValue(
totalValue,
"TOTAL",
items.length,
(amount) => Math.round(amount).toLocaleString()
);
totalCard.append(totalLabel, totalValue);
dashboard.append(totalCard);
STAGE_OPTIONS.forEach((stage) => {
const stageItems = items.filter((item) => item.ForecastGroup_c === stage);
const amount = stageItems.reduce((total, item) => {
const value = Number(getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]));
return Number.isFinite(value) ? total + value : total;
}, 0);
const card = document.createElement("section");
const label = document.createElement("span");
const value = document.createElement("strong");
card.className = "opportunities-extension-stage-dashboard-card";
card.setAttribute("data-stage", stage);
label.textContent = stage;
value.className = "opportunities-extension-stage-dashboard-value";
value.setAttribute("aria-label", `${stage} total amount`);
animateStageDashboardValue(
value,
stage,
amount,
createStageAmountFormatter(stageItems)
);
card.append(label, value);
dashboard.append(card);
});
}
function createStageAmountFormatter(items) {
const currencies = new Set(items
.map((item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]))
.filter(Boolean));
if (currencies.size === 1) {
const [currency] = currencies;
try {
const formatter = new Intl.NumberFormat(undefined, {
style: "currency",
currency,
maximumFractionDigits: 0
});
return (amount) => formatter.format(amount);
} catch (error) {
// Fall through to a neutral numeric total when the currency code is invalid.
}
}
return (amount) => amount.toLocaleString(undefined, {
maximumFractionDigits: 0
});
}
function animateStageDashboardValue(element, stage, target, format) {
const current = opportunitiesDashboardState.stageDashboardAmounts.get(stage) || 0;
opportunitiesDashboardState.stageDashboardAmounts.set(stage, target);
if (current === target) {
element.textContent = format(target);
return;
}
const startTime = performance.now();
const duration = 480;
function tick(now) {
const progress = Math.min((now - startTime) / duration, 1);
const easedProgress = 1 - Math.pow(1 - progress, 3);
const value = current + ((target - current) * easedProgress);
element.textContent = format(value);
if (progress < 1) {
requestAnimationFrame(tick);
} else {
element.textContent = format(target);
}
}
requestAnimationFrame(tick);
}
return { renderStageDashboard, createStageAmountFormatter, animateStageDashboardValue };
}

View File

@@ -0,0 +1,15 @@
function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) {
return {
selectedStages: new Set(STAGE_OPTIONS),
selectedStatuses: new Set(STATUS_OPTIONS),
selectedCustomerKeys: new Set(),
customerSearch: "",
selectedOwnerKeys: new Set(),
ownerSearch: "",
tableSearch: "",
hasSavedCustomerFilter: false,
hasSavedOwnerFilter: false,
customerFilterInitialized: false,
ownerFilterInitialized: false
};
}

View File

@@ -0,0 +1,402 @@
function createOpportunitiesFilters({
CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID,
CUSTOMER_FILTER_PANEL_ID,
CUSTOMER_FILTER_SEARCH_ID,
CUSTOMER_FILTER_SELECT_ALL_ID,
OPPORTUNITIES_COLUMNS,
OWNER_FILTER_BUTTON_ID,
OWNER_FILTER_LIST_ID,
OWNER_FILTER_PANEL_ID,
OWNER_FILTER_SEARCH_ID,
OWNER_FILTER_SELECT_ALL_ID,
STAGE_FILTERS_ID,
STAGE_OPTIONS,
STATUS_FILTERS_ID,
STATUS_OPTIONS,
getCustomerFilterSummary,
getCustomerKey,
getCustomerOptions,
getOwnerKey,
getOwnerOptions,
opportunitiesFilterState,
opportunitiesTableState,
renderOpportunitiesTable,
saveFilterPreferences,
stringifySearchValue,
environment
}) {
const { document } = environment;
function resetCustomerAndOwnerFiltersForPeriod() {
opportunitiesFilterState.selectedCustomerKeys = new Set();
opportunitiesFilterState.selectedOwnerKeys = new Set();
opportunitiesFilterState.customerSearch = "";
opportunitiesFilterState.ownerSearch = "";
opportunitiesFilterState.hasSavedCustomerFilter = false;
opportunitiesFilterState.hasSavedOwnerFilter = false;
opportunitiesFilterState.customerFilterInitialized = false;
opportunitiesFilterState.ownerFilterInitialized = false;
}
function renderStageFilterButtons() {
const controls = document.getElementById(STAGE_FILTERS_ID);
if (!controls) {
return;
}
controls.textContent = "";
STAGE_OPTIONS.forEach((stage) => {
const button = document.createElement("button");
const isPressed = opportunitiesFilterState.selectedStages.has(stage);
button.type = "button";
button.className = "opportunities-extension-stage-filter-button";
button.setAttribute("data-stage", stage);
button.setAttribute("aria-pressed", isPressed ? "true" : "false");
button.textContent = stage;
button.addEventListener("click", () => toggleStageFilter(stage));
controls.append(button);
});
}
function toggleStageFilter(stage) {
if (opportunitiesFilterState.selectedStages.has(stage)) {
opportunitiesFilterState.selectedStages.delete(stage);
} else {
opportunitiesFilterState.selectedStages.add(stage);
}
saveFilterPreferences();
renderStageFilterButtons();
renderOpportunitiesTable();
}
function renderStatusFilterButtons() {
const controls = document.getElementById(STATUS_FILTERS_ID);
if (!controls) {
return;
}
controls.textContent = "";
STATUS_OPTIONS.forEach((status) => {
const button = document.createElement("button");
const isPressed = opportunitiesFilterState.selectedStatuses.has(status);
button.type = "button";
button.className = "opportunities-extension-status-filter-button";
button.setAttribute("data-status", status);
button.setAttribute("aria-pressed", isPressed ? "true" : "false");
button.textContent = status;
button.addEventListener("click", () => toggleStatusFilter(status));
controls.append(button);
});
}
function toggleStatusFilter(status) {
if (opportunitiesFilterState.selectedStatuses.has(status)) {
opportunitiesFilterState.selectedStatuses.delete(status);
} else {
opportunitiesFilterState.selectedStatuses.add(status);
}
saveFilterPreferences();
renderStatusFilterButtons();
renderOpportunitiesTable();
}
function toggleCustomerFilterPanel() {
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
if (!panel || !button || button.disabled) {
return;
}
panel.hidden = !panel.hidden;
button.setAttribute("aria-expanded", panel.hidden ? "false" : "true");
if (!panel.hidden) {
const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID);
searchInput.focus();
}
}
function closeCustomerFilterPanel(restoreFocus = true) {
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
if (!panel || panel.hidden) {
return false;
}
panel.hidden = true;
button.setAttribute("aria-expanded", "false");
if (restoreFocus) {
button.focus();
}
return true;
}
function toggleOwnerFilterPanel() {
const panel = document.getElementById(OWNER_FILTER_PANEL_ID);
const button = document.getElementById(OWNER_FILTER_BUTTON_ID);
if (!panel || !button || button.disabled) {
return;
}
panel.hidden = !panel.hidden;
button.setAttribute("aria-expanded", panel.hidden ? "false" : "true");
if (!panel.hidden) {
const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID);
searchInput.focus();
}
}
function closeOwnerFilterPanel(restoreFocus = true) {
const panel = document.getElementById(OWNER_FILTER_PANEL_ID);
const button = document.getElementById(OWNER_FILTER_BUTTON_ID);
if (!panel || panel.hidden) {
return false;
}
panel.hidden = true;
button.setAttribute("aria-expanded", "false");
if (restoreFocus) {
button.focus();
}
return true;
}
function resetCustomerFilter(items) {
const customerOptions = getCustomerOptions(items);
if (!opportunitiesFilterState.customerFilterInitialized) {
opportunitiesFilterState.selectedCustomerKeys = new Set(customerOptions.map((customer) => customer.key));
opportunitiesFilterState.customerFilterInitialized = true;
}
}
function resetOwnerFilter(items) {
const ownerOptions = getOwnerOptions(items);
if (!opportunitiesFilterState.ownerFilterInitialized) {
opportunitiesFilterState.selectedOwnerKeys = new Set(ownerOptions.map((owner) => owner.key));
opportunitiesFilterState.ownerFilterInitialized = true;
}
}
function renderCustomerFilter() {
const trigger = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID);
const selectAllCheckbox = document.getElementById(CUSTOMER_FILTER_SELECT_ALL_ID);
const customerOptions = getCustomerOptions();
if (!trigger || !searchInput || !selectAllCheckbox) {
return;
}
const selectedCount = customerOptions.filter((customer) => opportunitiesFilterState.selectedCustomerKeys.has(customer.key)).length;
trigger.disabled = customerOptions.length === 0;
trigger.textContent = getCustomerFilterSummary(customerOptions.length, selectedCount);
searchInput.value = opportunitiesFilterState.customerSearch;
selectAllCheckbox.disabled = customerOptions.length === 0;
selectAllCheckbox.checked = customerOptions.length > 0 && selectedCount === customerOptions.length;
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < customerOptions.length;
renderCustomerFilterList();
}
function renderCustomerFilterList() {
const list = document.getElementById(CUSTOMER_FILTER_LIST_ID);
if (!list) {
return;
}
const normalizedSearch = opportunitiesFilterState.customerSearch.trim().toLocaleLowerCase();
const customerOptions = getCustomerOptions().filter((customer) => {
return customer.label.toLocaleLowerCase().includes(normalizedSearch);
});
list.textContent = "";
if (customerOptions.length === 0) {
const empty = document.createElement("p");
empty.className = "opportunities-extension-customer-filter-empty";
empty.textContent = opportunitiesFilterState.customerSearch ? "No matching customers." : "No customers available.";
list.append(empty);
return;
}
customerOptions.forEach((customer) => {
const option = document.createElement("label");
option.className = "opportunities-extension-customer-filter-option";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key);
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
opportunitiesFilterState.selectedCustomerKeys.add(customer.key);
} else {
opportunitiesFilterState.selectedCustomerKeys.delete(customer.key);
}
opportunitiesFilterState.hasSavedCustomerFilter = true;
saveFilterPreferences();
renderCustomerFilter();
renderOpportunitiesTable();
});
const label = document.createElement("span");
label.textContent = customer.label;
option.append(checkbox, label);
list.append(option);
});
}
function renderOwnerFilter() {
const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID);
const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID);
const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID);
const ownerOptions = getOwnerOptions();
if (!trigger || !searchInput || !selectAllCheckbox) {
return;
}
const selectedCount = ownerOptions.filter((owner) => opportunitiesFilterState.selectedOwnerKeys.has(owner.key)).length;
trigger.disabled = ownerOptions.length === 0;
trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount)
.replace("customer", "owner");
searchInput.value = opportunitiesFilterState.ownerSearch;
selectAllCheckbox.disabled = ownerOptions.length === 0;
selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length;
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length;
renderOwnerFilterList();
}
function renderOwnerFilterList() {
const list = document.getElementById(OWNER_FILTER_LIST_ID);
if (!list) {
return;
}
const normalizedSearch = opportunitiesFilterState.ownerSearch.trim().toLocaleLowerCase();
const ownerOptions = getOwnerOptions().filter((owner) => {
return owner.label.toLocaleLowerCase().includes(normalizedSearch);
});
list.textContent = "";
if (ownerOptions.length === 0) {
const empty = document.createElement("p");
empty.className = "opportunities-extension-customer-filter-empty";
empty.textContent = opportunitiesFilterState.ownerSearch ? "No matching owners." : "No owners available.";
list.append(empty);
return;
}
ownerOptions.forEach((owner) => {
const option = document.createElement("label");
option.className = "opportunities-extension-customer-filter-option";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key);
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
opportunitiesFilterState.selectedOwnerKeys.add(owner.key);
} else {
opportunitiesFilterState.selectedOwnerKeys.delete(owner.key);
}
opportunitiesFilterState.hasSavedOwnerFilter = true;
saveFilterPreferences();
renderOwnerFilter();
renderOpportunitiesTable();
});
const label = document.createElement("span");
label.textContent = owner.label;
option.append(checkbox, label);
list.append(option);
});
}
function getSortedOpportunities() {
const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase();
const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => {
return opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c)
&& matchesSelectedStatus(item.StatusCode)
&& opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item))
&& opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item))
&& matchesTableSearch(item, normalizedSearch);
});
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
if (!column) {
return items;
}
const direction = opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending" ? 1 : -1;
return items.sort((first, second) => {
const firstValue = column.value(first);
const secondValue = column.value(second);
const firstIsEmpty = firstValue === null || firstValue === undefined || firstValue === "";
const secondIsEmpty = secondValue === null || secondValue === undefined || secondValue === "";
if (firstIsEmpty || secondIsEmpty) {
if (firstIsEmpty && secondIsEmpty) {
return 0;
}
return firstIsEmpty ? 1 : -1;
}
const firstNumber = Number(firstValue);
const secondNumber = Number(secondValue);
if (Number.isFinite(firstNumber) && Number.isFinite(secondNumber)) {
return (firstNumber - secondNumber) * direction;
}
return String(firstValue).localeCompare(String(secondValue), undefined, {
numeric: true,
sensitivity: "base"
}) * direction;
});
}
function matchesTableSearch(item, normalizedSearch) {
if (!normalizedSearch) {
return true;
}
return OPPORTUNITIES_COLUMNS.some((column) => {
const rawValue = stringifySearchValue(column.value(item));
const displayValue = stringifySearchValue(column.display(item));
return `${rawValue} ${displayValue}`.toLocaleLowerCase().includes(normalizedSearch);
});
}
function matchesSelectedStatus(statusCode) {
if (statusCode === "WON_PENDING") {
return opportunitiesFilterState.selectedStatuses.has("WON");
}
return opportunitiesFilterState.selectedStatuses.has(statusCode);
}
return { resetCustomerAndOwnerFiltersForPeriod, renderStageFilterButtons, toggleStageFilter, renderStatusFilterButtons, toggleStatusFilter, toggleCustomerFilterPanel, closeCustomerFilterPanel, toggleOwnerFilterPanel, closeOwnerFilterPanel, resetCustomerFilter, resetOwnerFilter, renderCustomerFilter, renderCustomerFilterList, renderOwnerFilter, renderOwnerFilterList, getSortedOpportunities, matchesTableSearch, matchesSelectedStatus };
}

View File

@@ -0,0 +1,55 @@
function createOpportunitiesFiscalPeriods() {
function getFiscalPeriodRange(period, currentDate) {
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();
const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1;
const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3);
if (period === "Current Fiscal Year") {
const isBeforeFiscalYearStart = currentMonth < 5;
const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear;
const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1;
return createDateRange(startYear, 5, endYear, 4);
}
let quarterOffset;
let numberOfQuarters = 1;
if (period === "Current Quarter") {
quarterOffset = currentQuarterIndex;
} else if (period === "Next Quarter") {
quarterOffset = currentQuarterIndex + 1;
} else if (period === "Previous Quarter") {
quarterOffset = currentQuarterIndex - 1;
} else if (period === "4 Rolling Quarters (CQ + 3)") {
quarterOffset = currentQuarterIndex;
numberOfQuarters = 4;
} else {
return null;
}
const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1);
const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0);
return {
startCloseDate: formatOracleDate(start),
endCloseDate: formatOracleDate(end)
};
}
function createDateRange(startYear, startMonth, endYear, endMonth) {
return {
startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)),
endCloseDate: formatOracleDate(new Date(endYear, endMonth + 1, 0))
};
}
function formatOracleDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}T00:00:00.000Z`;
}
return { getFiscalPeriodRange, createDateRange, formatOracleDate };
}

View File

@@ -0,0 +1,174 @@
function createOpportunitiesFormatters({
opportunitiesTableState
}) {
function getCustomerOptions(items) {
const optionsByKey = new Map();
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
const key = getCustomerKey(item);
const label = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]);
if (key && label && !optionsByKey.has(key)) {
optionsByKey.set(key, { key, label: String(label) });
}
});
return Array.from(optionsByKey.values()).sort((first, second) => {
return first.label.localeCompare(second.label, undefined, {
sensitivity: "base"
});
});
}
function getCustomerKey(item) {
const partyId = getNestedValue(item, ["CustomerAccount", "PartyId"]);
const partyName = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]);
if (partyId !== null && partyId !== undefined && partyId !== "") {
return `party:${partyId}`;
}
return partyName ? `name:${partyName}` : "";
}
function getOwnerName(item) {
const owner = item.Owner;
if (typeof owner === "string" || typeof owner === "number") {
return owner;
}
if (owner && typeof owner === "object") {
const firstName = owner.PersonFirstName;
const lastName = owner.PersonLastName;
if (firstName || lastName) {
return [firstName, lastName].filter(Boolean).join(" ");
}
return owner.PartyName
|| owner.PartyUniqueName
|| owner.Name
|| owner.DisplayName
|| owner.ResourceName
|| owner.OwnerName
|| "";
}
return "";
}
function getCustomerFilterSummary(total, selected) {
if (total === 0) {
return "No customers";
}
if (selected === total) {
return "All customers";
}
if (selected === 0) {
return "No customers";
}
return `${selected} customer${selected === 1 ? "" : "s"}`;
}
function getOwnerOptions(items) {
const optionsByKey = new Map();
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
const key = getOwnerKey(item);
const label = getOwnerName(item);
if (key && label && !optionsByKey.has(key)) {
optionsByKey.set(key, { key, label: String(label) });
}
});
return Array.from(optionsByKey.values()).sort((first, second) => {
return first.label.localeCompare(second.label, undefined, {
sensitivity: "base"
});
});
}
function getOwnerKey(item) {
const owner = item.Owner;
const partyId = owner && typeof owner === "object" ? owner.PartyId : "";
const name = getOwnerName(item);
if (partyId !== null && partyId !== undefined && partyId !== "") {
return `party:${partyId}`;
}
return name ? `name:${name}` : "";
}
function stringifySearchValue(value) {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "object") {
return JSON.stringify(value);
}
return String(value);
}
function displayOpportunityValue(column, item) {
const value = column.display(item);
return value === null || value === undefined || value === "" ? "-" : String(value);
}
function getNestedValue(value, path) {
return path.reduce((result, key) => result && result[key], value);
}
function formatWinProbability(item) {
const value = getNestedValue(item, ["PrimaryRevenue", "WinProb"]);
const number = Number(value);
return Number.isFinite(number) ? `${number}%` : value;
}
function formatRevenue(item) {
const amount = getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]);
const currency = getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]);
const number = Number(amount);
if (!Number.isFinite(number)) {
return amount;
}
if (!currency) {
return number.toLocaleString();
}
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency
}).format(number);
} catch (error) {
return `${currency} ${number.toLocaleString()}`;
}
}
function formatOracleResponseDate(value, includeTime) {
if (typeof value !== "string") {
return value;
}
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/);
if (!match) {
return value;
}
const date = `${match[3]}/${match[2]}/${match[1]}`;
return includeTime && match[4] ? `${date} ${match[4]}:${match[5]}` : date;
}
return { getCustomerOptions, getCustomerKey, getOwnerName, getCustomerFilterSummary, getOwnerOptions, getOwnerKey, stringifySearchValue, displayOpportunityValue, getNestedValue, formatWinProbability, formatRevenue, formatOracleResponseDate };
}

View File

@@ -0,0 +1,118 @@
function createOpportunitiesLinks({
MODAL_ID,
environment
}) {
const { document, navigator, window } = environment;
function createDetailLink(label, href) {
if (label === null || label === undefined || label === "") {
const placeholder = document.createElement("span");
placeholder.textContent = "-";
return placeholder;
}
if (!href) {
const text = document.createElement("span");
text.textContent = String(label);
return text;
}
const link = document.createElement("a");
link.href = href;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = String(label);
return link;
}
function createCopyOpportunityButton(optyNumber) {
const button = document.createElement("button");
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
button.type = "button";
button.className = "opportunities-extension-copy-button";
button.setAttribute("aria-label", `Copy opportunity number ${optyNumber}`);
button.title = "Copy opportunity number";
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
copyOpportunityNumber(String(optyNumber));
});
icon.setAttribute("viewBox", "0 0 24 24");
icon.setAttribute("aria-hidden", "true");
path.setAttribute("d", "M8 8V5.5A2.5 2.5 0 0 1 10.5 3h8A2.5 2.5 0 0 1 21 5.5v8a2.5 2.5 0 0 1-2.5 2.5H16v2.5a2.5 2.5 0 0 1-2.5 2.5h-8A2.5 2.5 0 0 1 3 18.5v-8A2.5 2.5 0 0 1 5.5 8H8zm2 0h3.5A2.5 2.5 0 0 1 16 10.5v3.5h2.5a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5V8zm3.5 2h-8a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5z");
icon.append(path);
button.append(icon);
return button;
}
async function copyOpportunityNumber(optyNumber) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(optyNumber);
} else {
const input = document.createElement("textarea");
input.value = optyNumber;
input.setAttribute("readonly", "true");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.append(input);
input.select();
document.execCommand("copy");
input.remove();
}
showCopyConfirmation(`Opty Number ${optyNumber} copied.`);
} catch (error) {
showCopyConfirmation("Unable to copy opportunity number.", true);
}
}
function showCopyConfirmation(message, isError = false) {
const modal = document.getElementById(MODAL_ID);
if (!modal) {
return;
}
let toast = modal.querySelector(".opportunities-extension-copy-toast");
if (!toast) {
toast = document.createElement("div");
toast.className = "opportunities-extension-copy-toast";
toast.setAttribute("role", "status");
modal.append(toast);
}
toast.classList.toggle("opportunities-extension-copy-toast-error", isError);
toast.textContent = message;
toast.hidden = false;
window.clearTimeout(toast.copyTimeout);
toast.copyTimeout = window.setTimeout(() => {
toast.hidden = true;
}, 2400);
}
function createStageBadge(stage) {
const badge = document.createElement("span");
const normalizedStage = typeof stage === "string" ? stage.toUpperCase() : "";
badge.className = "opportunities-extension-stage-badge";
badge.setAttribute("data-stage", normalizedStage);
badge.textContent = normalizedStage || "-";
return badge;
}
function createStatusBadge(status) {
const badge = document.createElement("span");
const normalizedStatus = typeof status === "string" ? status.toUpperCase() : "";
badge.className = "opportunities-extension-status-badge";
badge.setAttribute("data-status", normalizedStatus);
badge.textContent = normalizedStatus || "-";
return badge;
}
return { createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge };
}

View File

@@ -0,0 +1,5 @@
function createOpportunitiesQueryState() {
return {
periodRequestVersion: 0
};
}

View File

@@ -0,0 +1,11 @@
function createOpportunitiesTableState() {
return {
opportunitiesTableState: {
items: [],
status: "idle",
message: "",
sortKey: "",
sortDirection: "ascending"
}
};
}

View File

@@ -0,0 +1,176 @@
function createOpportunitiesTable({
ACCOUNT_DETAIL_URL,
OPPORTUNITIES_COLUMNS,
OPPORTUNITIES_TABLE_ID,
OPPORTUNITY_DETAIL_URL,
createCloudConsumptionButton,
createCopyOpportunityButton,
createDetailLink,
createProductsCountButton,
createProductsDetailRow,
createStageBadge,
createStatusBadge,
displayOpportunityValue,
getNestedValue,
getSortedOpportunities,
opportunitiesTableState,
productsContentState,
renderCustomerFilter,
renderOwnerFilter,
renderStageDashboard,
resetCustomerFilter,
resetOwnerFilter,
environment
}) {
const { document } = environment;
function setOpportunitiesTableState(nextState) {
if (nextState.status === "ready" && Array.isArray(nextState.items)) {
resetCustomerFilter(nextState.items);
resetOwnerFilter(nextState.items);
}
opportunitiesTableState.opportunitiesTableState = {
...opportunitiesTableState.opportunitiesTableState,
...nextState,
sortKey: nextState.items ? "" : opportunitiesTableState.opportunitiesTableState.sortKey,
sortDirection: nextState.items ? "ascending" : opportunitiesTableState.opportunitiesTableState.sortDirection
};
renderOpportunitiesTable();
renderCustomerFilter();
renderOwnerFilter();
}
function renderOpportunitiesTable() {
const table = document.getElementById(OPPORTUNITIES_TABLE_ID);
if (!table) {
return;
}
table.textContent = "";
table.setAttribute("aria-busy", opportunitiesTableState.opportunitiesTableState.status === "loading" ? "true" : "false");
const caption = document.createElement("caption");
caption.className = "opportunities-extension-visually-hidden";
caption.textContent = "Opportunities";
const tableHead = document.createElement("thead");
const headerRow = document.createElement("tr");
OPPORTUNITIES_COLUMNS.forEach((column) => {
const header = document.createElement("th");
const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key;
header.scope = "col";
if (column.sortable === false) {
const label = document.createElement("span");
label.className = "opportunities-extension-table-header-label";
label.textContent = column.label;
header.append(label);
} else {
header.setAttribute("aria-sort", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
const sortButton = document.createElement("button");
sortButton.type = "button";
sortButton.className = "opportunities-extension-sort-button";
sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`);
sortButton.textContent = column.label;
sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key));
header.append(sortButton);
}
headerRow.append(header);
});
tableHead.append(headerRow);
const tableBody = document.createElement("tbody");
const items = getSortedOpportunities();
renderStageDashboard(items);
if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.className = "opportunities-extension-table-status";
cell.colSpan = OPPORTUNITIES_COLUMNS.length;
cell.textContent = opportunitiesTableState.opportunitiesTableState.message || "No opportunities found.";
row.append(cell);
tableBody.append(row);
} else {
items.forEach((item) => {
const row = document.createElement("tr");
OPPORTUNITIES_COLUMNS.forEach((column) => {
const cell = document.createElement("td");
if (column.key === "stage") {
cell.append(createStageBadge(column.display(item)));
} else if (column.key === "status") {
cell.append(createStatusBadge(column.display(item)));
} else if (column.key === "actions") {
const actionsContent = document.createElement("span");
actionsContent.className = "opportunities-extension-actions-content";
const productsResult = productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
if (productsResult) {
actionsContent.append(createProductsCountButton(item.OptyNumber, productsResult));
}
actionsContent.append(createCloudConsumptionButton(item));
cell.append(actionsContent);
} else if (column.key === "name" || column.key === "optyNumber") {
const opportunityLink = createDetailLink(
column.display(item),
item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : ""
);
if (column.key === "name" && item.OptyNumber) {
const nameCellContent = document.createElement("span");
nameCellContent.className = "opportunities-extension-name-content";
nameCellContent.append(opportunityLink);
cell.append(nameCellContent);
} else if (column.key === "optyNumber" && item.OptyNumber) {
const opportunityCellContent = document.createElement("span");
opportunityCellContent.className = "opportunities-extension-opty-number-content";
opportunityCellContent.append(opportunityLink, createCopyOpportunityButton(item.OptyNumber));
cell.append(opportunityCellContent);
} else {
cell.append(opportunityLink);
}
} else if (column.key === "customer") {
cell.append(createDetailLink(
column.display(item),
getNestedValue(item, ["CustomerAccount", "PartyId"])
? `${ACCOUNT_DETAIL_URL}${encodeURIComponent(getNestedValue(item, ["CustomerAccount", "PartyId"]))}`
: ""
));
} else {
cell.textContent = displayOpportunityValue(column, item);
}
row.append(cell);
});
tableBody.append(row);
if (item.OptyNumber && productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber))) {
tableBody.append(createProductsDetailRow(item));
}
});
}
table.append(caption, tableHead, tableBody);
}
function sortOpportunitiesBy(key) {
const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key;
opportunitiesTableState.opportunitiesTableState.sortKey = key;
opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending"
? "descending"
: "ascending";
renderOpportunitiesTable();
}
return { setOpportunitiesTableState, renderOpportunitiesTable, sortOpportunitiesBy };
}

View File

@@ -0,0 +1,6 @@
function createPreferencesState({ DEFAULT_OPPORTUNITY_TYPE_VALUES, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS } = {}) {
return {
selectedOpportunityTypeValues: new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES),
opptyProductsCacheHours: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS
};
}

View File

@@ -0,0 +1,124 @@
function createPreferencesStorage({
DEFAULT_OPPORTUNITY_TYPE_VALUES,
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
FILTER_PREFERENCES_STORAGE_KEY,
MODAL_ID,
OPPORTUNITY_TYPE_OPTIONS,
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
OPPTY_PRODUCTS_CACHE_OPTIONS,
PERIOD_OPTIONS,
STAGE_OPTIONS,
STATUS_OPTIONS,
opportunitiesFilterState,
environment
}) {
const { document } = environment;
const getLocalStorage = () => environment.localStorage;
function restoreFilterPreferences(select) {
try {
const rawPreferences = getLocalStorage().getItem(FILTER_PREFERENCES_STORAGE_KEY);
if (!rawPreferences) {
return;
}
const preferences = JSON.parse(rawPreferences);
const savedStages = Array.isArray(preferences.stages)
? preferences.stages.filter((stage) => STAGE_OPTIONS.includes(stage))
: null;
const savedStatuses = Array.isArray(preferences.statuses)
? preferences.statuses.filter((status) => STATUS_OPTIONS.includes(status))
: null;
if (savedStages) {
opportunitiesFilterState.selectedStages = new Set(savedStages);
}
if (savedStatuses) {
opportunitiesFilterState.selectedStatuses = new Set(savedStatuses);
}
if (Array.isArray(preferences.customerKeys)) {
opportunitiesFilterState.selectedCustomerKeys = new Set(preferences.customerKeys.map(String));
opportunitiesFilterState.hasSavedCustomerFilter = true;
opportunitiesFilterState.customerFilterInitialized = true;
}
if (Array.isArray(preferences.ownerKeys)) {
opportunitiesFilterState.selectedOwnerKeys = new Set(preferences.ownerKeys.map(String));
opportunitiesFilterState.hasSavedOwnerFilter = true;
opportunitiesFilterState.ownerFilterInitialized = true;
}
opportunitiesFilterState.customerSearch = typeof preferences.customerSearch === "string" ? preferences.customerSearch : "";
opportunitiesFilterState.ownerSearch = typeof preferences.ownerSearch === "string" ? preferences.ownerSearch : "";
opportunitiesFilterState.tableSearch = typeof preferences.tableSearch === "string" ? preferences.tableSearch : "";
if (PERIOD_OPTIONS.includes(preferences.period)) {
select.value = preferences.period;
}
} catch (error) {
// Ignore malformed or unavailable browser storage and use default filters.
}
}
function saveFilterPreferences(period) {
try {
const currentPeriod = period || document.querySelector(`#${MODAL_ID} select`)?.value || PERIOD_OPTIONS[0];
getLocalStorage().setItem(FILTER_PREFERENCES_STORAGE_KEY, JSON.stringify({
period: currentPeriod,
stages: Array.from(opportunitiesFilterState.selectedStages),
statuses: Array.from(opportunitiesFilterState.selectedStatuses),
customerKeys: opportunitiesFilterState.hasSavedCustomerFilter ? Array.from(opportunitiesFilterState.selectedCustomerKeys) : null,
ownerKeys: opportunitiesFilterState.hasSavedOwnerFilter ? Array.from(opportunitiesFilterState.selectedOwnerKeys) : null,
customerSearch: opportunitiesFilterState.customerSearch,
ownerSearch: opportunitiesFilterState.ownerSearch,
tableSearch: opportunitiesFilterState.tableSearch
}));
} catch (error) {
// Ignore unavailable browser storage; filters still work for the current modal.
}
}
function getSavedOpportunityTypeValues() {
try {
const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY);
if (!rawPreferences) {
return DEFAULT_OPPORTUNITY_TYPE_VALUES;
}
const preferences = JSON.parse(rawPreferences);
const values = Array.isArray(preferences.opportunityTypeValues) ? preferences.opportunityTypeValues : null;
if (!values) {
return DEFAULT_OPPORTUNITY_TYPE_VALUES;
}
const allowedValues = new Set(OPPORTUNITY_TYPE_OPTIONS.map((option) => option.value));
return values.filter((value) => allowedValues.has(value));
} catch (error) {
return DEFAULT_OPPORTUNITY_TYPE_VALUES;
}
}
function getSavedOpptyProductsCacheHours() {
try {
const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY);
if (!rawPreferences) {
return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
}
const preferences = JSON.parse(rawPreferences);
const cacheHours = Number(preferences.opptyProductsCacheHours);
return OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === cacheHours)
? cacheHours
: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
} catch (error) {
return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
}
}
return { restoreFilterPreferences, saveFilterPreferences, getSavedOpportunityTypeValues, getSavedOpptyProductsCacheHours };
}

View File

@@ -0,0 +1,165 @@
function createPreferencesView({
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
MODAL_ID,
OPPORTUNITY_TYPE_OPTIONS,
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
OPPTY_PRODUCTS_CACHE_OPTIONS,
PERIOD_OPTIONS,
PREFERENCES_MODAL_ID,
createCloseIcon,
getSavedOpportunityTypeValues,
getSavedOpptyProductsCacheHours,
preferencesState,
requestOpportunitiesForPeriod,
environment
}) {
const { document } = environment;
const getLocalStorage = () => environment.localStorage;
function openPreferencesModal() {
const overlay = document.getElementById(MODAL_ID);
if (!overlay) {
return;
}
const existingModal = document.getElementById(PREFERENCES_MODAL_ID);
if (existingModal) {
const savedValues = new Set(getSavedOpportunityTypeValues());
existingModal.querySelectorAll("input[name='opportunityTypeView']").forEach((checkbox) => {
checkbox.checked = savedValues.has(checkbox.value);
});
const cacheSelect = existingModal.querySelector("select[name='opptyProductsCacheHours']");
if (cacheSelect) {
cacheSelect.value = String(getSavedOpptyProductsCacheHours());
}
existingModal.hidden = false;
existingModal.querySelector("input")?.focus();
return;
}
const preferencesModal = document.createElement("section");
preferencesModal.id = PREFERENCES_MODAL_ID;
preferencesModal.className = "opportunities-extension-preferences-modal";
preferencesModal.setAttribute("role", "dialog");
preferencesModal.setAttribute("aria-modal", "true");
preferencesModal.setAttribute("aria-labelledby", "opportunities-extension-preferences-title");
const dialog = document.createElement("div");
dialog.className = "opportunities-extension-preferences-dialog";
const header = document.createElement("header");
header.className = "opportunities-extension-preferences-header";
const title = document.createElement("h2");
title.id = "opportunities-extension-preferences-title";
title.textContent = "Preferences";
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Fechar preferências");
closeButton.title = "Close";
closeButton.append(createCloseIcon());
closeButton.addEventListener("click", closePreferencesModal);
header.append(title, closeButton);
const content = document.createElement("div");
content.className = "opportunities-extension-preferences-content";
const fieldset = document.createElement("fieldset");
fieldset.className = "opportunities-extension-preferences-fieldset";
const legend = document.createElement("legend");
legend.textContent = "Opportunity Type View";
fieldset.append(legend);
const selectedValues = new Set(getSavedOpportunityTypeValues());
const options = document.createElement("div");
options.className = "opportunities-extension-preferences-options";
OPPORTUNITY_TYPE_OPTIONS.forEach((option) => {
const label = document.createElement("label");
label.className = "opportunities-extension-preferences-option";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.name = "opportunityTypeView";
checkbox.value = option.value;
checkbox.checked = selectedValues.has(option.value);
const text = document.createElement("span");
text.textContent = option.label;
label.append(checkbox, text);
options.append(label);
});
fieldset.append(options);
const cacheField = document.createElement("label");
cacheField.className = "opportunities-extension-preferences-cache-field";
const cacheLabel = document.createElement("span");
cacheLabel.textContent = "Product cache duration";
const cacheSelect = document.createElement("select");
cacheSelect.name = "opptyProductsCacheHours";
cacheSelect.setAttribute("aria-label", "Product cache duration");
OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => {
const optionElement = document.createElement("option");
optionElement.value = String(option.value);
optionElement.textContent = option.label;
optionElement.selected = option.value === getSavedOpptyProductsCacheHours();
cacheSelect.append(optionElement);
});
cacheField.append(cacheLabel, cacheSelect);
content.append(fieldset, cacheField);
const footer = document.createElement("footer");
footer.className = "opportunities-extension-preferences-footer";
const saveButton = document.createElement("button");
saveButton.type = "button";
saveButton.className = "opportunities-extension-button opportunities-extension-button-primary";
saveButton.textContent = "Save";
saveButton.addEventListener("click", () => {
const selectedValues = Array.from(preferencesModal.querySelectorAll("input[name='opportunityTypeView']:checked"), (checkbox) => checkbox.value);
const selectedCacheHours = Number(preferencesModal.querySelector("select[name='opptyProductsCacheHours']")?.value);
preferencesState.selectedOpportunityTypeValues = new Set(selectedValues);
preferencesState.opptyProductsCacheHours = OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === selectedCacheHours)
? selectedCacheHours
: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
try {
getLocalStorage().setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({
opportunityTypeValues: selectedValues,
opptyProductsCacheHours: preferencesState.opptyProductsCacheHours
}));
} catch (error) {
// Continue with the saved selection for the active modal when storage is unavailable.
}
closePreferencesModal();
const periodSelect = overlay.querySelector("select[name='opportunityPeriod']");
requestOpportunitiesForPeriod(periodSelect?.value || PERIOD_OPTIONS[0]);
});
footer.append(saveButton);
dialog.append(header, content, footer);
preferencesModal.append(dialog);
overlay.append(preferencesModal);
preferencesModal.querySelector("input")?.focus();
}
function closePreferencesModal() {
const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID);
if (preferencesModal) {
preferencesModal.hidden = true;
}
}
return { openPreferencesModal, closePreferencesModal };
}

View File

@@ -0,0 +1,107 @@
function createProductsBackgroundJobs({
messageTypes,
OPPTY_PRODUCTS_CONCURRENCY,
getOpptyProducts,
normalizeCacheMaxAge,
tabsSendMessage
}) {
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: messageTypes.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: messageTypes.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: messageTypes.opptyProductsProgress,
requestId,
state,
completed,
total: optyNumbers.length,
failed,
cacheHits
});
return {
ok: failed === 0,
requestId,
completed,
total: optyNumbers.length,
failed,
cacheHits
};
}
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(() => {});
}
return { requestOpptyProducts, sendProductsProgress };
}

View File

@@ -0,0 +1,7 @@
function createProductsCacheState() {
return {
productsCachePromise: null,
productsCacheWritePromise: Promise.resolve(),
inFlightProducts: new Map()
};
}

View File

@@ -0,0 +1,70 @@
function createProductsCache({
OPPTY_PRODUCTS_CACHE_KEY,
fetchAllOpptyProducts,
productsCacheState,
storageLocalGet,
storageLocalSet
}) {
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 (!productsCacheState.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(() => {
productsCacheState.inFlightProducts.delete(optyNumber);
});
productsCacheState.inFlightProducts.set(optyNumber, request);
}
const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber);
return {
items: fetchedResult.items,
cachedAt: fetchedResult.fetchedAt,
fromCache: false
};
}
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 (!productsCacheState.productsCachePromise) {
productsCacheState.productsCachePromise = storageLocalGet(OPPTY_PRODUCTS_CACHE_KEY)
.then((result) => result && typeof result[OPPTY_PRODUCTS_CACHE_KEY] === "object"
? result[OPPTY_PRODUCTS_CACHE_KEY]
: {})
.catch(() => ({}));
}
return productsCacheState.productsCachePromise;
}
function saveProductsCache(cache) {
productsCacheState.productsCacheWritePromise = productsCacheState.productsCacheWritePromise
.catch(() => {})
.then(() => storageLocalSet({ [OPPTY_PRODUCTS_CACHE_KEY]: cache }));
return productsCacheState.productsCacheWritePromise;
}
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
}

View File

@@ -0,0 +1,163 @@
function createProductsContentJobs({
messageTypes,
PRODUCTS_CACHE_TIMESTAMP_ID,
PRODUCTS_MESSAGE_LISTENER_KEY,
PRODUCTS_PROGRESS_ID,
preferencesState,
productsContentState,
renderOpportunitiesTable,
sendRuntimeMessage,
environment
}) {
const { browser, chrome, document, window } = environment;
function requestOpptyProductsInBackground(opportunities, token) {
const optyNumbers = Array.from(new Set(opportunities
.map((item) => item && item.OptyNumber)
.filter(Boolean)
.map(String)));
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
productsContentState.currentProductsRequestId = requestId;
productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsProgress = {
state: optyNumbers.length ? "running" : "complete",
completed: 0,
total: optyNumbers.length,
failed: 0,
cacheHits: 0
};
renderProductsProgress();
if (optyNumbers.length === 0) {
return;
}
sendRuntimeMessage({
type: messageTypes.requestOpptyProducts,
requestId,
accessToken: token,
optyNumbers,
cacheMaxAgeMs: preferencesState.opptyProductsCacheHours * 60 * 60 * 1000
}).then((response) => {
if (response && response.ok === false && response.requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") {
productsContentState.productsProgress.state = "error";
productsContentState.productsProgress.failed = productsContentState.productsProgress.total;
renderProductsProgress();
}
}).catch(() => {
if (requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") {
productsContentState.productsProgress.state = "error";
productsContentState.productsProgress.failed = productsContentState.productsProgress.total;
renderProductsProgress();
}
});
}
function ensureProductsBackgroundMessageHandler() {
if (window[PRODUCTS_MESSAGE_LISTENER_KEY]) {
return;
}
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.onMessage) {
return;
}
window[PRODUCTS_MESSAGE_LISTENER_KEY] = true;
runtimeApi.runtime.onMessage.addListener((message) => {
if (!message || message.type !== messageTypes.opptyProductsProgress || message.requestId !== productsContentState.currentProductsRequestId) {
return false;
}
if (message.optyNumber) {
productsContentState.opptyProductsByNumber.set(String(message.optyNumber), {
items: Array.isArray(message.items) ? message.items : [],
fromCache: Boolean(message.fromCache),
cachedAt: Number(message.cachedAt) || 0,
error: message.error || ""
});
}
if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) {
productsContentState.productsLastCachedAt = Number(message.cachedAt);
}
productsContentState.productsProgress = {
state: message.state || productsContentState.productsProgress.state,
completed: Number(message.completed) || 0,
total: Number(message.total) || productsContentState.productsProgress.total,
failed: Number(message.failed) || 0,
cacheHits: Number(message.cacheHits) || 0
};
renderProductsProgress();
queueProductsTableRender();
return false;
});
}
function renderProductsProgress() {
const badge = document.getElementById(PRODUCTS_PROGRESS_ID);
if (!badge) {
return;
}
badge.hidden = productsContentState.productsProgress.state === "idle";
badge.setAttribute("data-state", productsContentState.productsProgress.state);
if (productsContentState.productsProgress.state === "running") {
badge.textContent = `Products ${productsContentState.productsProgress.completed}/${productsContentState.productsProgress.total}`;
badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`;
} else if (productsContentState.productsProgress.state === "complete") {
badge.textContent = `Products ready ${productsContentState.productsProgress.total}`;
badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`;
} else if (productsContentState.productsProgress.state === "complete-with-errors") {
badge.textContent = `Products ${productsContentState.productsProgress.total - productsContentState.productsProgress.failed}/${productsContentState.productsProgress.total}`;
badge.title = `${productsContentState.productsProgress.failed} product requests failed`;
} else if (productsContentState.productsProgress.state === "error") {
badge.textContent = "Products unavailable";
badge.title = "Unable to load opportunity products";
} else {
badge.textContent = "Products ready 0";
}
const timestamp = document.getElementById(PRODUCTS_CACHE_TIMESTAMP_ID);
if (timestamp) {
timestamp.hidden = !productsContentState.productsLastCachedAt;
timestamp.textContent = productsContentState.productsLastCachedAt
? `Last update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}`
: "";
timestamp.title = productsContentState.productsLastCachedAt
? `Latest product update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}`
: "";
}
}
function formatProductsCacheTimestamp(timestamp) {
try {
return new Intl.DateTimeFormat("en-GB", {
dateStyle: "short",
timeStyle: "short"
}).format(new Date(timestamp));
} catch (error) {
return new Date(timestamp).toLocaleString();
}
}
function queueProductsTableRender() {
if (productsContentState.productsRenderQueued) {
return;
}
productsContentState.productsRenderQueued = true;
window.requestAnimationFrame(() => {
productsContentState.productsRenderQueued = false;
renderOpportunitiesTable();
});
}
return { requestOpptyProductsInBackground, ensureProductsBackgroundMessageHandler, renderProductsProgress, formatProductsCacheTimestamp, queueProductsTableRender };
}

View File

@@ -0,0 +1,16 @@
function createProductsContentState() {
return {
currentProductsRequestId: "",
productsRenderQueued: false,
opptyProductsByNumber: new Map(),
expandedProductOptyNumbers: new Set(),
productsProgress: {
state: "idle",
completed: 0,
total: 0,
failed: 0,
cacheHits: 0
},
productsLastCachedAt: 0
};
}

View File

@@ -0,0 +1,152 @@
function createProductsDetails({
OPPORTUNITIES_COLUMNS,
createAiSummaryAlert,
formatOracleResponseDate,
productsContentState,
renderOpportunitiesTable,
requestGenerateAiSummary,
environment
}) {
const { document } = environment;
function createProductsCountButton(optyNumber, productsResult) {
const normalizedOptyNumber = String(optyNumber);
const button = document.createElement("button");
const count = Array.isArray(productsResult.items) ? productsResult.items.length : 0;
const isExpanded = productsContentState.expandedProductOptyNumbers.has(normalizedOptyNumber);
button.type = "button";
button.className = "opportunities-extension-products-count";
button.textContent = String(count);
button.setAttribute("aria-expanded", String(isExpanded));
button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`);
button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`;
button.classList.toggle("opportunities-extension-products-count-error", Boolean(productsResult.error));
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (isExpanded) {
productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber);
} else {
productsContentState.expandedProductOptyNumbers.add(normalizedOptyNumber);
requestGenerateAiSummary(normalizedOptyNumber);
}
renderOpportunitiesTable();
});
return button;
}
function createProductsDetailRow(opportunity) {
const row = document.createElement("tr");
const cell = document.createElement("td");
const optyNumber = String(opportunity.OptyNumber);
const productsResult = productsContentState.opptyProductsByNumber.get(optyNumber);
const products = productsResult && Array.isArray(productsResult.items) ? productsResult.items : [];
row.className = "opportunities-extension-products-detail-row";
cell.colSpan = OPPORTUNITIES_COLUMNS.length;
cell.className = "opportunities-extension-products-detail-cell";
const panel = document.createElement("section");
panel.className = "opportunities-extension-products-detail";
const heading = document.createElement("div");
heading.className = "opportunities-extension-products-detail-heading";
const title = document.createElement("strong");
title.textContent = `Products for ${optyNumber}`;
const summary = document.createElement("span");
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
heading.append(title, summary);
panel.append(heading);
panel.append(createAiSummaryAlert(optyNumber));
if (productsResult && productsResult.error) {
const error = document.createElement("p");
error.className = "opportunities-extension-products-empty";
error.textContent = productsResult.error;
panel.append(error);
} else if (products.length === 0) {
const empty = document.createElement("p");
empty.className = "opportunities-extension-products-empty";
empty.textContent = "No products found for this opportunity.";
panel.append(empty);
} else {
const table = document.createElement("table");
table.className = "opportunities-extension-products-table";
table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`);
const columns = [
{ label: "Product Group", value: (item) => item.ProdGroupName || "-" },
{ label: "Workload", value: (item) => item.WorkloadName_c || "-" },
{ label: "Currency", value: (item) => item.RevnAmountCurcyCode || "-" },
{ label: "Amount", value: (item) => formatProductAmount(item.RevnAmount, item.RevnAmountCurcyCode) },
{ label: "Type", value: (item) => item.TypeCode || "-" },
{ label: "Status", value: (item) => item.StatusCode || "-" },
{ label: "Win Probability", value: (item) => formatProductWinProbability(item.WinProb) },
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
{ label: "Ramp Months", value: (item) => formatProductNumber(item.RampMonths_c) }
];
const head = document.createElement("thead");
const headerRow = document.createElement("tr");
columns.forEach((column) => {
const header = document.createElement("th");
header.scope = "col";
header.textContent = column.label;
headerRow.append(header);
});
head.append(headerRow);
const body = document.createElement("tbody");
products.forEach((product) => {
const productRow = document.createElement("tr");
columns.forEach((column) => {
const productCell = document.createElement("td");
productCell.textContent = String(column.value(product));
productRow.append(productCell);
});
body.append(productRow);
});
table.append(head, body);
panel.append(table);
}
cell.append(panel);
row.append(cell);
return row;
}
function formatProductNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(number) : "-";
}
function formatProductAmount(value, currency) {
const amount = Number(value);
if (!Number.isFinite(amount)) {
return "-";
}
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency || "USD"
}).format(amount);
} catch (error) {
return `${currency || ""} ${formatProductNumber(amount)}`.trim();
}
}
function formatProductWinProbability(value) {
const number = Number(value);
return Number.isFinite(number) ? `${number}%` : "-";
}
return { createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
}

View File

@@ -0,0 +1,190 @@
function createPlatformBackgroundApi({
runtimeApi,
environment
}) {
const { browser, setTimeout } = environment;
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));
}
function cookiesGetAll(details) {
if (runtimeApi.cookies.getAll.length <= 1) {
return runtimeApi.cookies.getAll(details);
}
return new Promise((resolve, reject) => {
runtimeApi.cookies.getAll(details, (cookies) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(cookies);
});
});
}
function cookiesGet(details) {
const cleanDetails = removeUndefinedValues(details);
if (runtimeApi.cookies.get.length <= 1) {
return runtimeApi.cookies.get(cleanDetails);
}
return new Promise((resolve, reject) => {
runtimeApi.cookies.get(cleanDetails, (cookie) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(cookie);
});
});
}
function cookiesGetAllCookieStores() {
if (!runtimeApi.cookies.getAllCookieStores) {
return Promise.resolve([{ id: undefined }]);
}
if (runtimeApi.cookies.getAllCookieStores.length === 0) {
return runtimeApi.cookies.getAllCookieStores();
}
return new Promise((resolve, reject) => {
runtimeApi.cookies.getAllCookieStores((stores) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(stores);
});
});
}
function removeUndefinedValues(details) {
return Object.fromEntries(
Object.entries(details).filter((entry) => entry[1] !== undefined)
);
}
return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues };
}

View File

@@ -0,0 +1,120 @@
function createPlatformContentMessages({
messageTypes,
environment
}) {
const { browser, chrome } = environment;
function sendRuntimeMessage(message) {
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {
return Promise.reject(new Error("Runtime messaging API unavailable."));
}
if (typeof browser !== "undefined") {
return runtimeApi.runtime.sendMessage(message);
}
return new Promise((resolve, reject) => {
runtimeApi.runtime.sendMessage(message, (response) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(response);
});
});
}
function getXsrfTokenFromExtensionCookies() {
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {
return Promise.resolve({
token: "",
cookieName: "",
source: "unavailable cookies API",
error: "Runtime messaging API unavailable."
});
}
return new Promise((resolve) => {
runtimeApi.runtime.sendMessage({
type: messageTypes.getXsrfToken
}, (response) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
resolve({
token: "",
cookieName: "",
source: "cookies API",
matchedCookieNames: [],
lookupDetails: [],
error: lastError.message
});
return;
}
resolve({
token: response && response.ok ? response.token : "",
cookieName: response && response.ok ? response.cookieName : "",
source: "cookies API",
matchedCookieNames: response && response.matchedCookieNames ? response.matchedCookieNames : [],
lookupDetails: response && response.lookupDetails ? response.lookupDetails : [],
error: response && response.error ? response.error : ""
});
});
});
}
function refreshXsrfCookie(reason) {
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {
return Promise.resolve({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
reason,
error: "Runtime messaging API unavailable."
});
}
return new Promise((resolve) => {
runtimeApi.runtime.sendMessage({
type: messageTypes.refreshXsrfCookie
}, (response) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
resolve({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
reason,
error: lastError.message
});
return;
}
resolve({
...(response || {
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
error: "Empty refresh response."
}),
reason
});
});
});
}
return { sendRuntimeMessage, getXsrfTokenFromExtensionCookies, refreshXsrfCookie };
}

View File

@@ -0,0 +1,166 @@
function createFusionOpportunities({
OPPORTUNITIES_PAGE_LIMIT,
OPPORTUNITIES_QUERY_URL,
isCurrentPeriodRequest,
logRequestFailure,
logRequestStart,
logRequestSuccess,
maskToken,
getOpportunityTypeValues,
readResponseBodyForDebug,
environment
}) {
const { fetch } = environment;
async function requestOpportunitiesPage(token, period, dateRange, offset, accumulatedItems, requestVersion, page) {
if (!isCurrentPeriodRequest(requestVersion)) {
return null;
}
const payload = createOpportunitiesQueryPayload(dateRange, offset);
const requestBody = JSON.stringify(payload);
const requestEntry = logRequestStart({
name: "requestOpportunities",
method: "POST",
url: OPPORTUNITIES_QUERY_URL,
headers: {
Accept: "application/json",
Authorization: `Bearer ${maskToken(token)}`,
"Content-Type": "application/json",
Origin: "https://eeho.fa.us2.oraclecloud.com",
Preference: "transient"
},
body: JSON.stringify(payload, null, 2),
metadata: {
period,
page,
offset,
startCloseDate: dateRange.startCloseDate,
endCloseDate: dateRange.endCloseDate
}
});
try {
const response = await fetch(OPPORTUNITIES_QUERY_URL, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Origin: "https://eeho.fa.us2.oraclecloud.com",
Preference: "transient"
},
body: requestBody
});
const responseBody = await readResponseBodyForDebug(response);
logRequestSuccess(requestEntry, response, responseBody);
if (!response.ok) {
throw new Error(`Opportunities query failed with status ${response.status}.`);
}
const responseData = await response.json();
const pageItems = Array.isArray(responseData.items) ? responseData.items : [];
const allItems = accumulatedItems.concat(pageItems);
if (!responseData.hasMore) {
return allItems;
}
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("Opportunities query returned hasMore without additional results.");
}
return requestOpportunitiesPage(
token,
period,
dateRange,
currentOffset + receivedCount,
allItems,
requestVersion,
page + 1
);
} catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed.");
throw error;
}
}
function createOpportunitiesQueryPayload(dateRange, offset) {
return {
aggregationResults: false,
applications: ["ORACLE-ISS-APP"],
onlyData: true,
entity: "Opportunity",
q: {
op: "$and",
criteria: [
{
op: "$or",
criteria: Array.from(getOpportunityTypeValues(), (value) => ({
op: "$eq",
attribute: "RecordSet",
value
}))
},
{
op: "$wi",
attribute: "EffectiveDate",
value1: dateRange.startCloseDate,
value2: dateRange.endCloseDate,
dynamicDate: false
}
]
},
keywords: null,
keywordsFields: [
"Name",
"OptyNumber",
"PrimaryRevenue.WinProb",
"CustomerAccount",
"PrimaryRevenue.RevnAmount",
"EffectiveDate",
"StatusCode",
"DealRisk_c",
"Owner",
"ForecastGroup_c",
"LastUpdateDate",
"PrimaryRevenue.RevnAmountCurcyCode",
"OptyId"
],
fields: [
"Name",
"OptyNumber",
"PrimaryRevenue.WinProb",
"CustomerAccount",
"PrimaryRevenue.RevnAmount",
"EffectiveDate",
"StatusCode",
"DealRisk_c",
"Owner",
"ForecastGroup_c",
"LastUpdateDate",
"PrimaryRevenue.RevnAmountCurcyCode",
"OptyId",
"CustomerAccount.PartyUniqueName",
"CustomerAccount.PartyId",
"CustomerAccount.PartyNumber"
],
sort: [],
language: "en",
skipInValidFields: true,
skipHiddenFromUIFields: true,
copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162",
limit: OPPORTUNITIES_PAGE_LIMIT,
offset
};
}
return { requestOpportunitiesPage, createOpportunitiesQueryPayload };
}

View File

@@ -0,0 +1,65 @@
function createFusionProducts({
OPPTY_PRODUCTS_FIELDS,
OPPTY_PRODUCTS_MAX_PAGES,
OPPTY_PRODUCTS_PAGE_LIMIT,
ORACLE_DOMAIN,
environment
}) {
const { fetch } = environment;
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();
}
return { fetchAllOpptyProducts, createOpptyProductsUrl };
}

View File

@@ -0,0 +1,169 @@
function createHttpBackgroundJson({
DEBUG_BODY_LIMIT,
environment
}) {
const { fetch } = environment;
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.`);
}
try {
return parseJsonResponse(responsePayload.text, requestName);
} catch (error) {
error.httpStatus = response.status;
debugEntry.status = "failed";
debugEntry.error = error.message;
throw error;
}
}
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) {
const invalidJsonError = new Error(`${requestName} returned an invalid JSON response.`);
invalidJsonError.code = "INVALID_JSON_RESPONSE";
throw invalidJsonError;
}
}
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);
}
}
return { fetchJson, createBackgroundDebugEntry, completeBackgroundDebugEntry, appendBackgroundDebugEntry, readResponsePayload, parseJsonResponse, createDebugBody, normalizeDebugHeaders, headersToObject, formatDebugRequestBody };
}

View File

@@ -0,0 +1,75 @@
function createSpaAccounts({
ACCOUNT_PLANS_PAYLOAD,
ACCOUNT_PLANS_URL,
ACCOUNT_SUMMARY_URL,
extractAccountPlans,
fetchJson,
findAccountPlanId,
normalizeMessageAuthorization
}) {
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;
}
}
return { requestAccountPlans };
}

View File

@@ -0,0 +1,58 @@
function createSpaConsumption({
CONSUMPTION_TREND_REQUESTS,
CONSUMPTION_TREND_URL,
fetchJson,
normalizeMessageAuthorization
}) {
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;
}
}
return { requestConsumptionTrend };
}

View File

@@ -0,0 +1,61 @@
function createSpaResponseShapes() {
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;
}
return { findNestedObject, findAccountPlanId, extractAccountPlans };
}

12
src/shared/messages.js Normal file
View File

@@ -0,0 +1,12 @@
/** Stable wire names. All payloads remain plain, serializable objects. */
function createMessageTypes() {
return Object.freeze({
getXsrfToken: "opportunitiesExtension.getXsrfToken",
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress",
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
});
}

View File

@@ -1,7 +1,5 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const { loadApplication } = require("./support/application.cjs");
class Element {
constructor(tag) {
@@ -28,24 +26,6 @@ function setup(mockSalesToken = true) {
static now() { return now; }
}
let visibleAlerts = [];
const source = fs.readFileSync(path.join(__dirname, "../src/content.js"), "utf8");
const marker = " if (!isAllowedPage()) {";
assert.ok(source.includes(marker));
const instrumented = source.replace(marker, `
accessToken = "test-bearer-secret";
getXsrfToken = async () => ({ token: "test-xsrf-cookie", cookieName: "XSRF-TOKEN-DYNAMIC", source: "test" });
refreshXsrfCookie = async () => ({ refreshed: false });
${mockSalesToken ? 'requestTokenRelaySalesCommon = async () => "test-sales-common-secret";' : ''}
renderDebugPanel = () => {};
renderOpportunitiesTable = () => {};
globalThis.helpers = {
requestGenerateAiSummary, createProductsCountButton, createProductsDetailRow,
createAiSummaryAlert, aiSummariesByOptyNumber, expandedProductOptyNumbers,
opptyProductsByNumber, requestLog, formatHeaderBlock, requestTokenRelay,
getAccessToken: () => accessToken, requestTokenRelaySalesCommon
};
return;
${marker}`);
const context = {
document: {
createElement: (tag) => new Element(tag),
@@ -54,8 +34,21 @@ ${marker}`);
fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })),
Intl, Date: TestDate, Map, Set
};
vm.runInNewContext(instrumented, context);
return { ...context.helpers, calls, advance: (ms) => { now += ms; }, show: (alerts) => { visibleAlerts = alerts; } };
const app = loadApplication("content", context);
app.authSessionState.accessToken = "test-bearer-secret";
app.authXsrfContent.getXsrfToken = async () => ({ token: "test-xsrf-cookie", cookieName: "XSRF-TOKEN-DYNAMIC", source: "test" });
app.platformContentMessages.refreshXsrfCookie = async () => ({ refreshed: false });
if (mockSalesToken) app.authSalesCommon.requestTokenRelaySalesCommon = async () => "test-sales-common-secret";
app.debugContentView.renderDebugPanel = () => {};
app.opportunitiesTable.renderOpportunitiesTable = () => {};
return {
...app.aiSummaryContent, ...app.productsDetails, ...app.aiSummaryState,
...app.productsContentState, ...app.debugContentLog, ...app.debugState,
requestTokenRelay: app.authTokenRelay.requestTokenRelay,
requestTokenRelaySalesCommon: app.authSalesCommon.requestTokenRelaySalesCommon,
getAccessToken: () => app.authSessionState.accessToken,
calls, advance: (ms) => { now += ms; }, show: (alerts) => { visibleAlerts = alerts; }
};
}
function json(data, status = 200) {

View File

@@ -0,0 +1,75 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const { createHash } = require("node:crypto");
const { test } = require("node:test");
const { sources, composeScript } = require("../scripts/compose.cjs");
const { loadApplication } = require("./support/application.cjs");
const root = path.resolve(__dirname, "..");
const read = file => fs.readFileSync(path.join(root, file), "utf8");
const json = value => JSON.parse(JSON.stringify(value));
test("every source belongs to an explicit runtime composition, with no duplicate factories", () => {
const actual = fs.readdirSync(path.join(root, "src"), { recursive: true })
.filter(file => file.endsWith(".js")).map(file => `src/${file.replaceAll(path.sep, "/")}`).sort();
assert.deepEqual([...new Set(Object.values(sources).flat())].sort(), actual);
for (const [target, files] of Object.entries(sources)) {
assert.equal(new Set(files).size, files.length);
const factories = files.flatMap(file => [...read(file).matchAll(/^function (\w+)\(/gm)].map(match => match[1]));
assert.equal(new Set(factories).size, factories.length, target);
assert.equal(composeScript(target), composeScript(target));
assert.doesNotThrow(() => new vm.Script(composeScript(target)));
for (const file of files) {
const source = read(file);
assert.doesNotMatch(source, /\beval\s*\(|\bnew\s+Function\s*\(|\bimportScripts\s*\(|\brequire\s*\(|^\s*(?:import|export)\s/m, file);
if (file.endsWith("/application.js") || file.endsWith("/main.js")) continue;
const declaration = source.match(/^function (\w+)\(/m)[1];
for (const factory of factories.filter(name => name !== declaration)) {
assert.doesNotMatch(source, new RegExp(`\\b${factory}\\s*\\(`), `${file} must receive dependencies, not construct ${factory}`);
}
}
}
assert.deepEqual(sources.content.filter(file => sources.background.includes(file)), ["src/shared/messages.js"]);
});
test("Redwood cascade is byte-identical to the characterized original", () => {
const hash = createHash("sha256").update(read("src/content/styles/redwood.css")).digest("hex");
assert.equal(hash, "eaeb7e53757d48a3095f9111c5029d2c923d0dcab92af23454ccd14bdcfc81c4");
});
test("fiscal module can run alone without DOM, browser APIs or network", () => {
const factory = new vm.Script(`${read("src/features/opportunities/fiscal-periods.js")}\ncreateOpportunitiesFiscalPeriods;`).runInNewContext();
assert.deepEqual(json(factory().getFiscalPeriodRange("Current Fiscal Year", new Date(2027, 0, 15))), {
startCloseDate: "2026-06-01T00:00:00.000Z", endCloseDate: "2027-05-31T00:00:00.000Z"
});
});
test("per-frame state and credentials do not leak between application instances", () => {
const a = loadApplication("content");
const b = loadApplication("content");
for (const key of Object.keys(a).filter(key => key.endsWith("State"))) assert.notEqual(a[key], b[key], key);
a.authSessionState.accessToken = "synthetic-general";
a.authSalesCommonState.salesCommonToken = { access_token: "synthetic-scoped", expiresAt: 123 };
a.productsContentState.opptyProductsByNumber.set("TEST", { items: [] });
a.aiSummaryState.aiSummariesByOptyNumber.set("TEST", { summaryText: "Synthetic" });
assert.equal(b.authSessionState.accessToken, "");
assert.equal(b.authSalesCommonState.salesCommonToken, null);
assert.equal(b.productsContentState.opptyProductsByNumber.size, 0);
assert.equal(b.aiSummaryState.aiSummariesByOptyNumber.size, 0);
});
test("blocked localStorage access remains lazy and handled by preference fallbacks", () => {
const context = {};
Object.defineProperty(context, "localStorage", { get() { throw new Error("Storage blocked"); } });
const app = loadApplication("content", context);
assert.doesNotThrow(() => app.preferencesStorage.getSavedOpportunityTypeValues());
assert.equal(app.preferencesStorage.getSavedOpptyProductsCacheHours(), 24);
});
test("visual fixtures load modules without eval or rewriting source markers", () => {
for (const file of ["tests/fixtures/consumption-trend.html", "tests/fixtures/extension.js"]) {
assert.doesNotMatch(read(file), /\beval\s*\(|testableSource|src\/content\.js/);
}
});

View File

@@ -0,0 +1,344 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const { root, plain, response, loadContent, loadBackground } = require("./support/characterization.cjs");
const { composeScript } = require("./support/application.cjs");
const PREFIX = "opportunitiesExtension.";
const FILTER_KEY = "opportunities-extension-filter-preferences";
const TYPE_KEY = "opportunities-extension-opportunity-type-preferences";
const CACHE_KEY = "opportunitiesExtension.opptyProductsCache.v1";
const DAY = 86400000;
const range = { startCloseDate: "2026-06-01T00:00:00.000Z", endCloseDate: "2026-08-31T00:00:00.000Z" };
const drain = () => new Promise(resolve => setImmediate(resolve));
const productRequest = (requestId, optyNumbers = ["SYNTH-1"]) => ({
type: PREFIX + "requestOpptyProducts", requestId, optyNumbers, accessToken: "synthetic-products-token"
});
test("content bootstrap ignores other origins and preserves the existing tile/listener on reinjection", () => {
const source = composeScript("content");
const script = new vm.Script(source);
assert.doesNotThrow(() => script.runInNewContext({
window: { location: { hostname: "unrelated.example", pathname: "/" } },
document: { getElementById() { assert.fail("DOM touched on unrelated origin"); } }
}));
let listeners = 0;
const context = vm.createContext({
window: { location: { hostname: "eeho.fa.us2.oraclecloud.com", pathname: "/fscmUI/faces/FuseWelcome" } },
document: { getElementById: () => ({}) },
chrome: { runtime: { onMessage: { addListener() { listeners++; } } } },
MutationObserver: class { constructor() { assert.fail("Observer unnecessary when tile already exists"); } }
});
script.runInContext(context);
script.runInContext(context);
assert.equal(listeners, 1);
});
test("distribution contracts: manifests, execution boundaries and deterministic composition", () => {
const base = {
manifest_version: 3, name: "Opportunities Extension",
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "1.0.1", permissions: ["cookies", "storage"],
host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*"],
content_scripts: [{
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
js: ["content.js"], all_frames: true, run_at: "document_idle"
}]
};
for (const target of ["chromium", "firefox"]) {
const expected = target === "chromium"
? { ...base, background: { service_worker: "background.js" } }
: { ...base, background: { scripts: ["background.js"] }, browser_specific_settings: { gecko: { id: "opportunities-extension@local.dev" } } };
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, "dist", target, "manifest.json"))), expected);
for (const file of ["content.js", "background.js"]) {
const output = fs.readFileSync(path.join(root, "dist", target, file), "utf8");
assert.equal(output, composeScript(file.replace(".js", "")));
assert.doesNotThrow(() => new vm.Script(output, { filename: `${target}/${file}` }));
}
}
});
test("fiscal dates preserve quarter/year boundaries, leap year and unsupported renewals", () => {
const h = loadContent();
const cases = [
["Current Quarter", [2026, 5, 1], "2026-06-01", "2026-08-31"],
["Current Quarter", [2026, 7, 31], "2026-06-01", "2026-08-31"],
["Current Quarter", [2026, 8, 1], "2026-09-01", "2026-11-30"],
["Current Quarter", [2028, 1, 29], "2027-12-01", "2028-02-29"],
["Next Quarter", [2026, 10, 30], "2026-12-01", "2027-02-28"],
["Previous Quarter", [2026, 7, 24], "2026-03-01", "2026-05-31"],
["Current Fiscal Year", [2026, 4, 31], "2025-06-01", "2026-05-31"],
["Current Fiscal Year", [2026, 5, 1], "2026-06-01", "2027-05-31"],
["Current Fiscal Year", [2027, 0, 1], "2026-06-01", "2027-05-31"],
["4 Rolling Quarters (CQ + 3)", [2026, 8, 16], "2026-09-01", "2027-08-31"]
];
for (const [period, date, start, end] of cases) {
assert.deepEqual(plain(h.getFiscalPeriodRange(period, new Date(...date))), {
startCloseDate: start + "T00:00:00.000Z", endCloseDate: end + "T00:00:00.000Z"
});
}
assert.equal(h.getFiscalPeriodRange("RENEWALS (Current + Past Due)", new Date()), null);
});
test("opportunity query preserves fields, record-set OR, date AND, and pagination payload", () => {
const h = loadContent();
const types = ["ORA_MYSALESTEAMOPTIES", "ORA_MYASSGTERROPTIES", "ORA_CREDITRECEIVER_ISME"];
h.setTypes(types);
const fields = ["Name", "OptyNumber", "PrimaryRevenue.WinProb", "CustomerAccount", "PrimaryRevenue.RevnAmount", "EffectiveDate", "StatusCode", "DealRisk_c", "Owner", "ForecastGroup_c", "LastUpdateDate", "PrimaryRevenue.RevnAmountCurcyCode", "OptyId"];
assert.deepEqual(plain(h.createOpportunitiesQueryPayload(range, 15)), {
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, entity: "Opportunity",
q: { op: "$and", criteria: [
{ op: "$or", criteria: types.map(value => ({ op: "$eq", attribute: "RecordSet", value })) },
{ op: "$wi", attribute: "EffectiveDate", value1: range.startCloseDate, value2: range.endCloseDate, dynamicDate: false }
] }, keywords: null, keywordsFields: fields,
fields: [...fields, "CustomerAccount.PartyUniqueName", "CustomerAccount.PartyId", "CustomerAccount.PartyNumber"],
sort: [], language: "en", skipInValidFields: true, skipHiddenFromUIFields: true,
copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", limit: 15, offset: 15
});
});
test("opportunity pagination uses response offsets/count, ignores stale versions and detects stalled pages", async () => {
const calls = [];
const h = loadContent({ fetch: async (url, options) => {
calls.push({ url, options });
return response(calls.length === 1
? { items: [{ OptyNumber: "S1" }], offset: 0, count: 1, hasMore: true }
: { items: [{ OptyNumber: "S2" }], offset: 1, count: 1, hasMore: false });
} });
const items = await h.requestOpportunitiesPage("synthetic-token", "Current Quarter", range, 0, [], 0, 1);
assert.deepEqual(plain(items), [{ OptyNumber: "S1" }, { OptyNumber: "S2" }]);
assert.deepEqual(calls.map(call => JSON.parse(call.options.body).offset), [0, 1]);
assert.equal(calls[0].url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/11.13.18.05/custom-actions/queries");
assert.deepEqual(plain(calls[0].options.headers), {
Accept: "application/json", Authorization: "Bearer synthetic-token", "Content-Type": "application/json",
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
});
assert.equal(calls[0].options.method, "POST");
assert.equal(calls[0].options.credentials, "include");
h.setVersion(1);
assert.equal(await h.requestOpportunitiesPage("unused", "Current Quarter", range, 0, [], 0, 1), null);
assert.equal(calls.length, 2);
const stalled = loadContent({ fetch: async () => response({ items: [], count: 0, hasMore: true }) });
await assert.rejects(stalled.requestOpportunitiesPage("synthetic", "Current Quarter", range, 0, [], 0, 1), /without additional results/);
});
const opportunity = (number, status, amount) => ({
Name: `Synthetic cloud ${number}`, OptyNumber: number, StatusCode: status, ForecastGroup_c: "PIPELINE",
CustomerAccount: { PartyId: "customer-1", PartyUniqueName: "Synthetic Customer", PartyNumber: "party-1" },
Owner: { PartyId: "owner-1", PersonFirstName: "Test", PersonLastName: "Owner", PartyName: "Long Test Owner Name" },
PrimaryRevenue: { RevnAmount: amount, RevnAmountCurcyCode: "USD", WinProb: 60 },
EffectiveDate: "2026-08-28", LastUpdateDate: "2026-08-28T13:40:00"
});
test("filter storage round-trip, WON_PENDING membership, search, sorting and reference resets", () => {
const preferences = { period: "Next Quarter", stages: ["PIPELINE"], statuses: ["WON"],
customerKeys: ["party:customer-1"], ownerKeys: ["party:owner-1"],
customerSearch: "Synthetic", ownerSearch: "Test", tableSearch: "cloud" };
const storage = new Map([[FILTER_KEY, JSON.stringify(preferences)]]);
const h = loadContent({ storage });
const select = { value: "Current Quarter" };
h.restoreFilterPreferences(select);
assert.equal(select.value, "Next Quarter");
const items = [opportunity("S1", "WON", "100"), opportunity("S2", "WON_PENDING", "20"), opportunity("S3", "OPEN", "5")];
h.setTable(items, "revenue");
assert.deepEqual(plain(h.getSortedOpportunities()).map(item => item.OptyNumber), ["S2", "S1"]);
assert.equal(h.getSortedOpportunities()[0].StatusCode, "WON_PENDING");
assert.equal(h.getOwnerName(items[0]), "Test Owner");
assert.equal(h.matchesTableSearch(items[0], "28/08/2026"), true);
assert.equal(h.matchesTableSearch(items[0], "test owner"), true);
h.saveFilterPreferences(select.value);
assert.deepEqual(JSON.parse(storage.get(FILTER_KEY)), preferences);
const reopened = loadContent({ storage });
reopened.restoreFilterPreferences({ value: "Current Quarter" });
assert.equal(reopened.filters().tableSearch, "cloud");
reopened.resetCustomerAndOwnerFiltersForPeriod();
const nextItems = [...items, { ...opportunity("S4", "WON", "30"), CustomerAccount: { PartyId: "customer-2", PartyUniqueName: "Another Customer" }, Owner: { PartyId: "owner-2", PartyName: "Another Owner" } }];
reopened.setTable(nextItems);
assert.deepEqual(plain(reopened.filters().customerKeys).sort(), ["party:customer-1", "party:customer-2"]);
assert.deepEqual(plain(reopened.filters().ownerKeys).sort(), ["party:owner-1", "party:owner-2"]);
assert.equal(reopened.filters().tableSearch, "cloud");
assert.equal(reopened.filters().customerSearch, "");
assert.equal(reopened.filters().ownerSearch, "");
});
test("malformed preferences fall back, while theme/type/cache keys retain their schema", () => {
const storage = new Map([[FILTER_KEY, "not JSON"], ["opportunities-extension-theme", "dark"],
[TYPE_KEY, JSON.stringify({ opportunityTypeValues: ["ORA_MYOPTIES", "UNKNOWN"], opptyProductsCacheHours: 24 })]]);
const h = loadContent({ storage });
assert.doesNotThrow(() => h.restoreFilterPreferences({ value: "Current Quarter" }));
assert.equal(h.getSavedTheme(), "dark");
assert.deepEqual(plain(h.getSavedOpportunityTypeValues()), ["ORA_MYOPTIES"]);
assert.equal(h.getSavedOpptyProductsCacheHours(), 24);
assert.equal(h.filters().tableSearch, "");
});
test("tokenrelay missing-cookie recovery and HTTP failure do not change the request contract", async () => {
let reads = 0;
const calls = [];
const h = loadContent({ xsrf: async () => ({ token: ++reads === 1 ? "" : "synthetic-xsrf", cookieName: "XSRF-TOKEN-SYNTHETIC" }),
fetch: async (url, options) => { calls.push({ url, options }); return response({ access_token: "synthetic-access" }); } });
assert.equal(await h.requestTokenRelay(), "synthetic-access");
assert.equal(h.refreshes.length, 1);
assert.equal(calls.length, 1);
assert.equal(calls[0].options.headers["x-xsrf-token"], "synthetic-xsrf");
assert.equal(calls[0].options.method, "GET");
for (const status of [400, 401, 403]) {
let attempts = 0;
const rejected = loadContent({ xsrf: async () => ({ token: "synthetic-xsrf" }),
fetch: async () => { attempts++; return response({}, status); } });
await assert.rejects(rejected.requestTokenRelay(), /failed with status/);
assert.equal(attempts, 2);
assert.equal(rejected.refreshes.length, 1);
}
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: products paginate, route progress to sender frame and reuse persisted cache after restart`, async () => {
const calls = [];
const storage = {};
const h = loadBackground({ family, storage, fetch: async (url, options) => {
calls.push({ url, options });
const offset = Number(new URL(url).searchParams.get("offset"));
return response({ items: [{ RevnNumber: `SYNTH-${offset}` }], count: 1, offset, hasMore: offset === 0 });
} });
const result = await h.dispatch(productRequest("job-1", ["SYNTH-1", "SYNTH-1"]));
assert.deepEqual(result, { ok: true, requestId: "job-1", completed: 1, total: 1, failed: 0, cacheHits: 0 });
assert.equal(calls.length, 2);
assert.equal(new URL(calls[0].url).pathname, "/crmRestApi/rest/rv:be91c002-2e5d-4ed2-a37e-e0c837bf141f/en/11.13.18.05:9/opportunities/SYNTH-1/child/ChildRevenue");
assert.equal(new URL(calls[0].url).searchParams.get("q"), "(SplitTypeCode!='DETAILCHILDSPLIT') AND (RecurTypeCode!='CHILDRECUR')");
assert.equal(new URL(calls[0].url).searchParams.get("limit"), "15");
assert.equal(new URL(calls[1].url).searchParams.get("offset"), "1");
assert.equal(calls[0].options.headers.Authorization, "Bearer synthetic-products-token");
assert.ok(h.progress.every(event => event.tabId === 7 && event.options.frameId === 2));
assert.equal(h.progress[0].message.type, PREFIX + "opptyProductsProgress");
assert.equal(h.progress[0].message.items.length, 2);
assert.equal(h.progress.at(-1).message.state, "complete");
assert.deepEqual(Object.keys(storage[CACHE_KEY]["SYNTH-1"]).sort(), ["fetchedAt", "items"]);
const restarted = loadBackground({ family, storage });
assert.equal((await restarted.dispatch(productRequest("job-2"))).cacheHits, 1);
h.advance(DAY);
await h.dispatch(productRequest("job-3"));
assert.equal(calls.length, 4, "default TTL expires at 24 hours");
});
test(`${family}: product queue has four workers, shares in-flight results and reports partial failures`, async () => {
const calls = [];
const h = loadBackground({ family, fetch: (url) => new Promise(resolve => calls.push({ url, resolve })) });
const first = h.dispatch(productRequest("concurrent-1", ["S1", "S2", "S3", "S4", "S5"]));
await drain();
assert.equal(calls.length, 4);
const duplicate = h.dispatch(productRequest("concurrent-2", ["S1"]));
await drain();
assert.equal(calls.length, 4);
calls[0].resolve(response({ items: [{ RevnNumber: "R1" }], hasMore: false }));
await drain();
assert.equal(calls.length, 5);
calls[1].resolve(response({}, 500));
for (const call of calls.slice(2)) call.resolve(response({ items: [], hasMore: false }));
assert.equal((await duplicate).ok, true);
const result = await first;
assert.equal(result.failed, 1);
assert.equal(result.completed, 5);
assert.equal(h.progress.filter(event => event.message.requestId === "concurrent-1").at(-1).message.state, "complete-with-errors");
});
test(`${family}: custom product TTL and zero-count pagination failures`, async () => {
let calls = 0;
const h = loadBackground({ family, fetch: async () => { calls++; return response({ items: [], hasMore: false }); } });
const message = { ...productRequest("custom-ttl"), cacheMaxAgeMs: 1000 };
await h.dispatch(message);
h.advance(999);
assert.equal((await h.dispatch(message)).cacheHits, 1);
assert.equal(calls, 1);
h.advance(1);
assert.equal((await h.dispatch(message)).cacheHits, 0);
assert.equal(calls, 2);
const stalled = loadBackground({ family, fetch: async () => response({ items: [], count: 0, hasMore: true }) });
assert.equal((await stalled.dispatch(productRequest("stalled"))).failed, 1);
assert.match(stalled.progress[0].message.error, /without additional results/);
});
test(`${family}: dynamic XSRF cookie lookup, missing-cookie result and temporary-tab cleanup`, async () => {
const cookie = { name: "XSRF-TOKEN-SYNTHETIC", value: "synthetic-xsrf", domain: "eeho.fa.us2.oraclecloud.com", path: "/", storeId: "test-store" };
const h = loadBackground({ family, cookies: () => [cookie] });
const result = await h.dispatch({ type: PREFIX + "getXsrfToken" });
assert.equal(result.ok, true);
assert.equal(result.cookieName, cookie.name);
assert.equal(result.token, cookie.value);
assert.ok(h.cookieQueries.some(query => query.storeId === "test-store"));
const missing = loadBackground({ family });
assert.equal((await missing.dispatch({ type: PREFIX + "getXsrfToken" })).token, "");
const refresh = await missing.dispatch({ type: PREFIX + "refreshXsrfCookie" });
assert.equal(refresh.refreshed, false);
assert.equal(refresh.durationMs, 15000);
assert.deepEqual(missing.removedTabs, [91]);
assert.equal(missing.tabs[0].active, false);
assert.equal(missing.tabs[0].windowId, 3);
assert.equal(missing.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined);
let refreshHarness;
refreshHarness = loadBackground({ family, cookies: () => refreshHarness.tabs.length ? [cookie] : [] });
assert.equal((await refreshHarness.dispatch({ type: PREFIX + "refreshXsrfCookie" })).refreshed, true);
assert.deepEqual(refreshHarness.removedTabs, [91]);
});
test(`${family}: SPA token selection, account POST contracts and debug envelope`, async () => {
const calls = [];
const h = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
if (calls.length === 1) return response([
{ tokenService: "UNRELATED", uiaasHeader: { value: "synthetic-unused" } },
{ tokenService: "ACCP-TS", uiaasHeader: { value: "synthetic-account" } },
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "synthetic-consumption" } }
]);
if (calls.length === 2) return response({ items: [{ id: "synthetic/id" }] });
return response({ items: [{ planType: "Synthetic", subPlanNum: "plan-1" }] });
} });
const tokens = await h.dispatch({ type: PREFIX + "requestConsumerTokenService" });
const plans = await h.dispatch({ type: PREFIX + "requestAccountPlans", partyNumber: "synthetic-party", authorizations: tokens.authorizations });
assert.equal(plans.ok, true);
assert.equal(plans.accountPlanId, "synthetic/id");
assert.deepEqual(plans.plans, [{ planType: "Synthetic", subPlanNum: "plan-1" }]);
assert.equal(calls[1].url, "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-account/service/accountsummary");
assert.deepEqual(JSON.parse(calls[1].options.body), [{ key: "regId", value: "synthetic-party" }]);
assert.equal(calls[1].options.headers["account-ti-authorization"], "synthetic-account");
assert.equal(calls[2].options.headers["g2m-authorization"], "synthetic-consumption");
assert.equal(calls[2].url, "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/account/accountPlans/synthetic%2Fid");
assert.deepEqual(JSON.parse(calls[2].options.body), {
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"]
});
assert.ok(calls.slice(1).every(call => call.options.method === "POST" && call.options.credentials === "include"));
assert.deepEqual(plans.debugRequests.map(entry => entry.name), ["requestAccountSummary", "requestAccountPlans"]);
assert.ok(plans.debugRequests.every(entry => entry.httpStatus === 200 && entry.status === "success" && entry.durationMs >= 0));
});
test(`${family}: unknown/invalid messages fail without network; storage failure does not discard fetched products`, async () => {
const h = loadBackground({ family });
assert.equal(h.listener({ type: "unknown" }, {}, () => assert.fail("Unexpected reply")), false);
assert.equal((await h.dispatch({ type: PREFIX + "requestOpptyProducts" })).ok, false);
assert.equal((await h.dispatch({ type: PREFIX + "requestAccountPlans" })).ok, false);
assert.equal((await h.dispatch({ type: PREFIX + "requestConsumptionTrend", subPlanNum: "S1", period: "unsupported" })).ok, false);
const noStorage = loadBackground({ family, storageError: true, fetch: async () => response({ items: [{ RevnNumber: "R1" }], hasMore: false }) });
assert.equal((await noStorage.dispatch(productRequest("no-storage"))).ok, true);
assert.equal(noStorage.progress[0].message.items.length, 1);
});
test(`${family}: consumption network/HTTP/JSON failures retain the response and debug contracts`, async () => {
for (const failure of ["network", "http", "json"]) {
const h = loadBackground({ family, fetch: async () => {
if (failure === "network") throw new Error("Synthetic network error");
return failure === "http" ? response({}, 401) : new Response("<html>Synthetic fallback</html>");
} });
const result = await h.dispatch({ type: PREFIX + "requestConsumptionTrend", subPlanNum: "synthetic-plan", period: "weekly",
authorization: { name: "g2m-authorization", value: "synthetic-token" } });
assert.equal(result.ok, false);
assert.equal(result.debugRequests.length, 1);
assert.equal(result.debugRequests[0].name, "requestConsumptionServiceWeekly");
assert.equal(result.debugRequests[0].status, "failed");
assert.equal(result.debugRequests[0].httpStatus, failure === "network" ? null : failure === "http" ? 401 : 200);
assert.match(result.error, failure === "network" ? /Synthetic network error/ : failure === "http" ? /status 401/ : /invalid JSON/);
}
});
}

View File

@@ -1,9 +1,7 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const { composeScript } = require("./support/application.cjs");
const root = path.resolve(__dirname, "..");
function createResponse(body, contentType) {
return {
@@ -24,7 +22,7 @@ function createResponse(body, contentType) {
function loadBackground(responses, createdTabs, removedTabs, options = {}) {
let listener;
const source = fs.readFileSync(path.join(root, "src", "background.js"), "utf8");
const source = composeScript("background");
const context = {
chrome: {
runtime: {

View File

@@ -1,21 +1,9 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const { loadApplication, composeScript } = require("./support/application.cjs");
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: () => ({}) },
@@ -36,13 +24,12 @@ ${marker}`);
Math
};
vm.runInNewContext(instrumented, context);
return context.__consumptionTrendTest;
return loadApplication("content", context).consumptionModel;
}
function loadBackground(fetchCalls) {
let listener;
const source = fs.readFileSync(path.join(root, "src", "background.js"), "utf8");
const source = composeScript("background");
const headers = {
get(name) {
return name.toLowerCase() === "content-type" ? "application/json" : null;

View File

@@ -12,25 +12,21 @@
</head>
<body>
<div id="opportunities-extension-modal" class="opportunities-extension-modal" data-theme="light"></div>
<script src="/fixture-runtime.js"></script>
<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 app = window.createFixtureApplication({
fetch: () => Promise.reject(new Error("Network disabled in fixture")),
browser: undefined,
chrome: undefined
});
const harness = {
ensureStyles: app.contentStyles.ensureExtensionStyles,
createAccountPlanDetails: app.consumptionPlansView.createAccountPlanDetails,
createConsumptionTrendSection: app.consumptionTrendController.createConsumptionTrendSection,
renderConsumptionTrendChart: app.consumptionChart.renderConsumptionTrendChart
};
const plan = {
subPlanNum: "11276098",
@@ -63,7 +59,7 @@
return rows;
});
window.__trendHarness.ensureStyles();
harness.ensureStyles();
const overlay = document.getElementById("opportunities-extension-modal");
overlay.dataset.theme = new URLSearchParams(window.location.search).get("theme") === "dark"
? "dark"
@@ -82,13 +78,13 @@
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);
const trend = harness.createConsumptionTrendSection(plan);
panel.append(harness.createAccountPlanDetails(plan), trend);
content.append(tabs, panel);
dialog.append(header, content);
modal.append(dialog);
overlay.append(modal);
window.__trendHarness.renderConsumptionTrendChart(
harness.renderConsumptionTrendChart(
trend,
trend.querySelector(".opportunities-extension-consumption-chart"),
items,

13
tests/fixtures/extension.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Extension Synthetic Integration Fixture</title>
</head>
<body>
<button id="open" type="button">Open Opportunities</button>
<script src="/fixture-runtime.js"></script>
<script src="/extension.js"></script>
</body>
</html>

77
tests/fixtures/extension.js vendored Normal file
View File

@@ -0,0 +1,77 @@
(() => {
const listeners = [];
const storage = new Map();
const opportunities = Array.from({ length: 25 }, (_, index) => ({
OptyNumber: `TEST${index + 1}`,
Name: `Synthetic opportunity ${index + 1} - Database migration`,
CustomerAccount: { PartyUniqueName: "Example public sector customer (Example City, BR)", PartyId: "test-party", PartyNumber: "test-account" },
Owner: { PersonFirstName: "Example", PersonLastName: "Owner" },
PrimaryRevenue: { RevnAmountCurcyCode: "USD", RevnAmount: "25000", WinProb: 60 },
EffectiveDate: "2026-11-15T00:00:00Z",
LastUpdateDate: "2026-09-15T17:00:00Z",
ForecastGroup_c: ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"][index % 5],
StatusCode: ["OPEN", "CLOSED", "LOST", "WON", "WON_PENDING"][index % 5]
}));
const products = [{ ProdGroupName: "Migrate databases to cloud", WorkloadName_c: "Example workload", RevnAmountCurcyCode: "USD", RevnAmount: "25000", TypeCode: "NEWCOMMIT", StatusCode: "OPEN", WinProb: 60, EffectiveDate: "2026-11-15", ConsumptionStartDate_c: "2026-11-15", RampMonths_c: 3 }];
const trend = Array.from({ length: 30 }, (_, index) => ({
fyDate: `2026-09-${String((index % 28) + 1).padStart(2, "0")}`,
fiscalTime: [`${String((index % 28) + 1).padStart(2, "0")}-Sep-26`],
fiscalQtr: "FY27-Q2", currencyCode: "USD", arrCd: 0,
usageCategory: index % 4 ? "Funded Allocation" : "OVERAGE",
usedAmount: 1000 + index * 100
}));
const authorizations = {
accountSummary: { label: "Account-Ti-Authorization", value: "synthetic-accp" },
accountPlans: { label: "G2m-Authorization", value: "synthetic-ocicons" }
};
const runtime = {
onMessage: { addListener: listener => listeners.push(listener) },
async sendMessage(message) {
const operation = message.type.split(".").at(-1);
if (operation === "getXsrfToken") return { ok: true, token: "synthetic-xsrf", cookieName: "XSRF-TOKEN-TEST" };
if (operation === "requestConsumerTokenService") return { ok: true, authorizations, debugRequests: [] };
if (operation === "requestOpptyProducts") {
message.optyNumbers.forEach((optyNumber, index) => listeners.forEach(listener => listener({
type: "opportunitiesExtension.opptyProductsProgress", requestId: message.requestId,
optyNumber, items: products, cachedAt: Date.now(), completed: index + 1,
total: message.optyNumbers.length, state: index + 1 === message.optyNumbers.length ? "complete" : "running"
})));
return { ok: true, requestId: message.requestId };
}
if (operation === "requestAccountPlans") return {
ok: true, debugRequests: [], plans: ["ACTIVE", "EXPIRED"].map((hdrStatusCode, index) => ({
subPlanNum: `PLAN${index + 1}`, planType: "Universal Credits", hdrStatusCode,
bookingCustomer: "Example Customer", soldToPartyName: "Example Sold To",
contractId: "synthetic-contract", startDate: "2026-06-01", endDate: "2027-05-31"
}))
};
if (operation === "requestConsumptionTrend") return { ok: true, items: trend, debugRequests: [] };
throw new Error(`Unexpected synthetic message: ${operation}`);
}
};
const application = window.createFixtureApplication({
browser: { runtime: {
onMessage: runtime.onMessage,
sendMessage(message, callback) {
const result = runtime.sendMessage(message);
if (callback) result.then(callback);
return result;
}
} }, chrome: undefined,
localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) },
async fetch(input, options) {
const url = new URL(input);
let body;
if (url.pathname === "/fscmRestApi/tokenrelay") body = { access_token: "synthetic-fusion-token", expires_in: 3600 };
else if (url.pathname.endsWith("custom-actions/queries")) {
const { offset, limit } = JSON.parse(options.body);
body = { items: opportunities.slice(offset, offset + limit), totalResults: opportunities.length, count: Math.min(limit, opportunities.length - offset), hasMore: offset + limit < opportunities.length, offset, limit };
} else if (url.pathname.endsWith("summarizations/generate")) body = { summaryText: "Synthetic AI summary.\nText is displayed safely: <script>never executed</script>." };
else throw new Error(`Unexpected synthetic fetch: ${url.pathname}`);
return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } });
}
});
application.productsContentJobs.ensureProductsBackgroundMessageHandler();
document.getElementById("open").addEventListener("click", () => application.contentModal.openOpportunitiesModal());
application.contentModal.openOpportunitiesModal();
})();

View File

@@ -0,0 +1,11 @@
const vm = require("node:vm");
const { composeScript } = require("../../scripts/compose.cjs");
function loadApplication(target, globals = {}) {
const factory = new vm.Script(composeScript(target, { bootstrap: false }), {
filename: `${target}.test-bundle.js`
}).runInNewContext(globals);
return factory();
}
module.exports = { loadApplication, composeScript };

View File

@@ -0,0 +1,134 @@
const assert = require("node:assert/strict");
const path = require("node:path");
const vm = require("node:vm");
const { loadApplication, composeScript } = require("./application.cjs");
const root = path.resolve(__dirname, "../..");
const plain = (value) => JSON.parse(JSON.stringify(value));
const response = (body, status = 200) => new Response(JSON.stringify(body), {
status, headers: { "Content-Type": "application/json" }
});
const unexpectedFetch = async () => { throw new Error("Unexpected network request in isolated test"); };
function clock() {
let now = Date.UTC(2026, 8, 16, 12);
class ClockDate extends Date {
constructor(...args) { super(...(args.length ? args : [now])); }
static now() { return now; }
}
return { Date: ClockDate, advance: (ms) => { now += ms; } };
}
function loadContent({ fetch = unexpectedFetch, storage = new Map(), xsrf = null } = {}) {
const refreshes = [];
const context = {
fetch, testXsrf: xsrf, refreshes,
document: { querySelector: () => ({ value: "Current Quarter" }) },
localStorage: {
getItem: key => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, value)
},
Date, Intl, URL
};
const app = loadApplication("content", context);
app.debugContentView.renderDebugPanel = () => {};
if (xsrf) app.authXsrfContent.getXsrfToken = xsrf;
app.platformContentMessages.refreshXsrfCookie = async () => { refreshes.push(true); return { refreshed: true }; };
return {
...app.opportunitiesFiscalPeriods, ...app.fusionOpportunities, ...app.authTokenRelay,
...app.authSalesCommon, ...app.preferencesStorage, ...app.opportunitiesFilters,
...app.opportunitiesFormatters, ...app.contentTheme, ...app.debugState,
setVersion: value => { app.opportunitiesQueryState.periodRequestVersion = value; },
setTypes: values => { app.preferencesState.selectedOpportunityTypeValues = new Set(values); },
setTable: (items, sortKey = "", sortDirection = "ascending") => {
app.opportunitiesTableState.opportunitiesTableState = { items, sortKey, sortDirection };
app.opportunitiesFilters.resetCustomerFilter(items);
app.opportunitiesFilters.resetOwnerFilter(items);
},
filters: () => {
const state = app.opportunitiesFilterState;
return {
stages: Array.from(state.selectedStages), statuses: Array.from(state.selectedStatuses),
customerKeys: Array.from(state.selectedCustomerKeys), ownerKeys: Array.from(state.selectedOwnerKeys),
customerSearch: state.customerSearch, ownerSearch: state.ownerSearch, tableSearch: state.tableSearch
};
},
storage, refreshes
};
}
function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage = {}, cookies = () => [], storageError = false } = {}) {
const time = clock();
const listeners = [];
const progress = [];
const tabs = [];
const removedTabs = [];
const cookieQueries = [];
const api = { runtime: { onMessage: { addListener: fn => listeners.push(fn) } } };
const readCookies = details => { cookieQueries.push(plain(details)); return cookies(details); };
const getStored = key => ({ [key]: storage[key] === undefined ? undefined : plain(storage[key]) });
const store = value => Object.assign(storage, plain(value));
const openTab = details => { tabs.push(plain(details)); return { id: 91 }; };
const send = (tabId, message, options) => progress.push({ tabId, message: plain(message), options: plain(options || {}) });
if (family === "firefox") {
api.runtime.getBrowserInfo = async () => ({ name: "Firefox" });
api.storage = { local: {
get: async key => { if (storageError) throw new Error("Storage unavailable"); return getStored(key); },
set: async value => { if (storageError) throw new Error("Storage unavailable"); store(value); }
} };
api.tabs = { create: async details => openTab(details), remove: async id => removedTabs.push(id),
sendMessage: async (tabId, message, options) => send(tabId, message, options) };
api.cookies = {
get: async details => readCookies(details).find(cookie => cookie.name === details.name) || null,
getAll: async details => readCookies(details),
getAllCookieStores: async () => [{ id: "test-store" }]
};
} else {
const withStorageError = callback => {
api.runtime.lastError = storageError ? { message: "Storage unavailable" } : undefined;
try { callback(); } finally { delete api.runtime.lastError; }
};
api.storage = { local: {
get(key, callback) { withStorageError(() => callback(getStored(key))); },
set(value, callback) { withStorageError(() => { if (!storageError) store(value); callback(); }); }
} };
api.tabs = {
create(details, callback) { callback(openTab(details)); },
remove(id, callback) { removedTabs.push(id); callback(); },
sendMessage(tabId, message, options, callback) {
if (typeof options === "function") { callback = options; options = undefined; }
send(tabId, message, options); callback();
}
};
api.cookies = {
get(details, callback) { callback(readCookies(details).find(cookie => cookie.name === details.name) || null); },
getAll(details, callback) { callback(readCookies(details)); },
getAllCookieStores(callback) { callback([{ id: "test-store" }]); }
};
}
const context = {
[family === "firefox" ? "browser" : "chrome"]: api,
fetch, URL, Date: time.Date,
setTimeout: (callback, ms) => { time.advance(ms); return setTimeout(callback, 0); }
};
new vm.Script(composeScript("background"), { filename: "background.js" })
.runInNewContext(context);
assert.equal(listeners.length, 1, "Background must register one listener synchronously");
const sender = { id: "synthetic-extension", url: "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome",
tab: { id: 7, windowId: 3, cookieStoreId: "test-store" }, frameId: 2 };
function dispatch(message) {
return new Promise((resolve, reject) => {
let replies = 0;
const timer = setTimeout(() => reject(new Error("Message did not receive a response")), 2000);
const pending = listeners[0](message, sender, result => {
clearTimeout(timer);
assert.equal(++replies, 1, "Message must receive exactly one response");
resolve(plain(result));
});
if (pending !== true) { clearTimeout(timer); reject(new Error("Expected asynchronous response contract")); }
});
}
return { dispatch, listener: listeners[0], progress, tabs, removedTabs, cookieQueries, storage, advance: time.advance };
}
module.exports = { root, plain, response, loadContent, loadBackground };