21 Commits
v1.0.5 ... main

Author SHA1 Message Date
1758a1f27e Populated the OpenSpec config.yaml file 2026-10-31 04:58:23 -03:00
0d9e654b11 Implemented OpenSpec and created and updated its artifacts 2026-10-31 04:33:34 -03:00
0d0a7cfe14 Updated the URL used to obtain the SPA session and hid the Manage Time icon for SALES_REP users 2026-10-31 03:27:04 -03:00
d689807a50 When an Owner is selected, the Customer filter options will update to show the corresponding customers, and vice versa 2026-10-30 16:53:04 -03:00
2363e37aab Release 1.1.0 2026-10-30 14:53:03 -03:00
9cdb46b6c2 Added the ability to bookmark the extension, along with some visual adjustments 2026-10-30 14:48:00 -03:00
00bcfdc42e Improved the background request loading order 2026-10-29 17:35:20 -03:00
9b5e751179 Updated the structure of the RELEASE_NOTES.md file 2026-10-28 19:19:06 -03:00
88de9b81b7 Release 1.0.9 2026-10-28 11:47:48 -03:00
3fa212e561 Updated the process for retrieving records from CRMServiceRequests 2026-10-28 11:43:26 -03:00
318459cef8 Release 1.0.8 2026-10-27 11:52:59 -03:00
aff137b319 Adjusted the simplified display of the customer name 2026-10-27 11:49:56 -03:00
8d2110e8e8 Optimized the process for obtaining user sessions for SPA and comcipapic 2026-10-27 03:34:23 -03:00
1d5b28e95b Updated the calendar to improve opportunity display and navigation 2026-10-27 03:22:10 -03:00
7606643ff4 Adjusted typography, styles, and other visual elements to ensure consistent layouts 2026-10-26 19:14:18 -03:00
3229ce9c41 Implemented the Organization Chart feature to create customer organizational 2026-10-26 18:16:55 -03:00
bf48ed1dcb Release 1.0.7 2026-10-26 11:41:53 -03:00
45428c9626 Updated the logic to retrieve the default savedSearchNumber dynamically 2026-10-26 11:39:21 -03:00
0f1f399d1a Table sorting preferences will be saved and reused based on the user’s selections 2026-10-25 00:39:55 -03:00
19edee4e11 Release 1.0.6 2026-10-21 15:59:08 -03:00
ba61f91c53 Fixed the SPA authentication flow 2026-10-21 15:55:29 -03:00
187 changed files with 15086 additions and 2268 deletions

View File

@@ -0,0 +1 @@
codex

View File

@@ -0,0 +1,206 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. Also use when the user says "openspec apply", "opsx apply", or "openspec implement".
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.16.0"
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
Otherwise, with no root, what happens next depends on how this workflow was reached:
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
**Input**: Optionally specify a change name (e.g., `$openspec-apply-change (Codex) or /openspec-apply-change (other agents) add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and ask the user to select one
Always announce: "Using change: <name>" and how to override (e.g., `$openspec-apply-change (Codex) or /openspec-apply-change (other agents) <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status, source path, and source line
- Dynamic instruction based on current state
- Optional `context`: current required project instruction input from the selected root
- Optional `operationGuidance`: current advisory guidance for apply
- `missingArtifacts` (when present): required artifact ids with no output
**Handle states:**
- If `state: "blocked"`: show the message and pause implementation.
- If `missingArtifacts` is non-empty: suggest completing the missing artifacts. Run `openspec status --change "<name>" --json`, select the next `ready` artifact (not `skipped` or `blocked`), and use `openspec instructions "<artifact-id>" --change "<name>" --json` for its rules and template. Keep the selected `--store <id>` on both commands.
- Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked.
- If `state: "all_done"`: report that all tracked tasks are complete and suggest review or verification as appropriate before archiving
- Otherwise: proceed to implementation
Treat `context` as a required prompt-level input. Read and consider it, and
apply relevant project facts, conventions, and constraints while implementing.
Treat `operationGuidance` as optional additive advice. Read and consider every
entry, and follow entries that are applicable and compatible with the built-in
workflow.
Keep both fields separate from CLI-returned state, missing artifacts, tasks,
progress, `contextFiles`, and the built-in `instruction`. They are not
evidence of task completion, do not replace the built-in instruction, and do
not permit bypassing a blocked state. If context conflicts with the built-in
instruction, an explicit user choice, or a CLI-controlled value, report the
conflict and preserve the controlling value. If guidance is inapplicable or
conflicts with those controlling inputs, do not follow it and explain why.
These are prompt-level behavior contracts, not enforceable checks.
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
Do not copy `context` or `operationGuidance` verbatim into implementation
files or planning artifacts unless the user separately asks for that content.
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Before editing, confirm the checkbox at the returned `sourcePath` and `line` still matches the task description; if it does not, rerun the apply instructions and use the refreshed location
- Mark the task complete at its returned `sourcePath` and `line`: `- [ ]` → `- [x]`
- Rerun the apply instructions and confirm that task is now done and progress changed
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- A task needs work beyond what the spec and tasks describe, or you are tempted to drop, narrow, defer, or accept exceptions to specified behavior to make it fit → surface the added scope and ask; do not absorb it silently
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: report that tracked tasks are complete and suggest review or verification as appropriate before archiving
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tracked tasks are complete. Review or verify the change as appropriate
before archiving. You can archive this change with `$openspec-archive-change (Codex) or /openspec-archive-change (other agents)`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- When a task needs work beyond what the spec describes, surface the added scope and pause - never silently narrow, defer, or simplify away specified behavior
- Only mark a task `- [x]` when its specified behavior is fully implemented, not when it is partially done or deferred
- Use contextFiles from CLI output, don't assume specific file names
- Use each task's sourcePath and line to update its exact checkbox
- Do not use context or operation guidance as proof that a task is complete
- Apply relevant project context; report conflicts with controlling workflow inputs
- Consider every guidance entry; explain any inapplicable or conflicting advice
- Do not copy runtime context or operation guidance into implementation files or planning artifacts
- Preserve CLI-controlled blocked/ready/all-done behavior and completion criteria
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

View File

@@ -0,0 +1,221 @@
---
name: openspec-archive-change
description: Archive a completed OpenSpec change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. Also use when the user says "openspec archive" or "opsx archive".
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.16.0"
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
Otherwise, with no root, what happens next depends on how this workflow was reached:
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and ask the user to select one
When prompting, show only active changes (not already archived).
Include the schema used for each change if available.
Always announce: "Using change: <name>" and how to override (e.g., `$openspec-archive-change (Codex) or /openspec-archive-change (other agents) <other>`).
**Load current archive inputs before the existing archive checks:**
After resolving the selected change and planning root, run:
```bash
openspec instructions archive --change "<name>" --json
```
Keep the same selected-root flags on this command. This lookup is advisory and
optional: it only supplies extra prompt inputs, so it must never block archiving.
If it exits non-zero or returns invalid JSON — for example on an older CLI that
does not support this command yet — continue the archive workflow with no
context and no operation guidance. Do not report an error and do not stop.
A successful response may omit both optional fields. Treat `context` as a
required prompt-level input: read and consider it, and apply relevant project
facts, conventions, and constraints. Treat `operationGuidance` as optional
additive advice: read and consider every entry, and follow entries that are
applicable and compatible with the built-in archive workflow.
Keep both fields separate from built-in steps, explicit user choices, resolved
paths, CLI checks, and command contracts. If context conflicts with one of those
controlling inputs, report the conflict and preserve the controlling value. If
guidance is inapplicable or conflicts with a controlling input, do not follow it
and explain why. Do not infer replacement paths, skipped prompts, or flags from
either field, and do not copy their text verbatim into specs, change artifacts,
or archive summaries unless the user separately asks for it. These are
prompt-level behavior contracts, not enforceable checks.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done`, `skipped`, or other)
**If any artifacts are neither `done` nor `skipped`** (skipped artifacts satisfy the requirement - the change declares skip_specs):
- Display warning listing incomplete artifacts
- Ask the user to confirm they want to proceed
- Proceed if user confirms
3. **Check task completion status**
Run `openspec list --json` with the same selected-root flags and find the
entry in `changes` whose `name` exactly matches the selected change.
Require exactly one match and nonnegative integer `totalTasks` and
`completedTasks`, with `completedTasks <= totalTasks`. The CLI resolves
the schema's tracked task files, including custom artifact names, output
paths, and globs.
Incomplete tasks = `totalTasks - completedTasks`.
Do not infer task completion from artifact status or the absence of a
top-level `tasks.md`. If the lookup fails, returns invalid JSON, omits or
duplicates the selected change, or returns invalid counts, report the problem
and stop before syncing or archiving.
The CLI counts only `x`/`X` checkbox markers as complete;
other markers, including unfamiliar ones, remain incomplete.
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Ask the user to confirm they want to proceed
- Proceed if user confirms
**If `totalTasks` is zero:** Proceed without a task-related warning.
4. **Assess delta spec sync state**
Use `artifactPaths.specs.existingOutputPaths` from status JSON as the only
delta-spec source. If the `specs` entry is missing or
`existingOutputPaths` is empty, proceed without a sync prompt and do not infer
delta specs from other artifacts.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path)
- A missing main spec is **not automatically** "already synced". For a new capability, the main spec is an *output* of the sync, not an input:
- If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and mark that capability as sync-blocked. Never invent a requirement that has no current version.
- Otherwise, if the delta has only REMOVED requirements and the change's `.openspec.yaml` declares `retire_capabilities: true`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync.
- Otherwise, if the delta has no ADDED requirements, report that no sync is possible and mark that capability as sync-blocked. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. `openspec archive` refuses the unmarked REMOVED-only case with `Spec must have at least one requirement`.
- Otherwise, count the capability as needing sync and name it in the summary (`<capability-path>: new main spec will be created`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as `openspec archive` does.
- Determine what changes would be applied (adds, modifications, removals, renames)
- Continue assessing the remaining capabilities even when one is sync-blocked. Show a combined summary before prompting.
**Prompt options:**
- If any capability is sync-blocked: explain why and offer only "Archive without syncing", "Cancel"
- Otherwise, if changes needed: "Sync now (recommended)", "Archive without syncing"
- Otherwise, if already synced: "Archive now", "Sync anyway", "Cancel"
Route on the answer:
- "Cancel" — stop, do not archive
- "Archive without syncing" or "Archive now" — proceed to archive
- "Sync now" or "Sync anyway" — sync, then verify (below). Do not start any sync while a capability is sync-blocked; explain the blocker and repeat the available choices.
- Anything else — ask again rather than archiving
Before a selected sync writes any main spec, run
`openspec instructions specs --change "<name>" --json` once with the same
selected-root flags. Require a zero exit status and valid artifact-instruction
JSON. If the lookup fails or returns invalid JSON, report the error and stop
before writing any main spec or moving the change. A valid response with omitted
`rules` is the no-rules case. Apply returned `rules` only to the content and
form of main specs produced by this merge; do not use them as archive guidance,
change CLI behavior, or copy the rule text into any output file.
Then run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge) for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching `specs` instructions again. Do not delegate it to a background task — step 5 would move `changeRoot` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
If the sync reports any stop or blocking condition, treat the sync as failed.
Stop the archive immediately. Do not perform the post-sync content comparison and do not move its `changeRoot`.
Nothing has moved, so the user can fix the blocking condition or re-run the sync.
After the sync writes each main spec, verify its structure against the canonical sync contract:
- A new main spec starts with a `# <capability> Specification` title. An existing main spec keeps its title exactly as it is.
- Preserve existing `## Purpose` sections completely untouched for established main specs.
- For a new main spec, copy the delta `## Purpose` verbatim. Warn only if the purpose text is shorter than standard validation expects. Do not regenerate or rewrite existing authored purpose. If no usable `## Purpose` is provided, use the existing TBD Purpose behavior and warning.
- Verify that no delta-style section headers (`## ADDED Requirements`, `## MODIFIED Requirements`, `## REMOVED Requirements`, `## RENAMED Requirements`) remain in the main spec, adhering strictly to the sync workflow formatting rules.
- Requirement blocks the sync wrote or changed use `### Requirement:` headings, and their scenarios use `#### Scenario:` headings, under the spec's `## Requirements` section. Leave content the delta does not mention exactly as it is.
Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
- ADDED requirements present
- MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact
- REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving `## Requirements` empty), its main spec deleted rather than left empty.
- RENAMED requirements present under the new name and absent under the old one
If the sync failed, or any capability does not match, report what differs and stop — do not archive. Nothing has moved and `changeRoot` is intact, so the user can fix the mismatch or re-run the sync and start the archive again.
5. **Perform the archive**
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p "<planningHome.changesDir>/archive"
```
Generate the target name: use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-<change-name>`. Never stack a second date (same rule as `openspec archive`).
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory
```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/<target-name>"
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```markdown
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/<target-name>/
**Specs:** <"✓ Synced to main specs" only if the step 4 verification passed; otherwise "No delta specs" or "Sync skipped">
<"All artifacts complete. All tasks complete." — or, if archived with warnings, list them instead (e.g. "Archived with 2 incomplete tasks")>
```
**Guardrails**
- Announce the selected change; prompt for selection when it is ambiguous
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, run the `openspec-sync-specs` workflow inline (agent-driven)
- Never archive while a spec sync is still in flight — run the sync inline and verify the main specs before moving `changeRoot`
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
- Apply relevant runtime context and report conflicts; operation guidance remains advisory
- Consider every guidance entry and explain any inapplicable or conflicting advice
- Existing CLI checks, resolved paths, prompts, and command contracts are unchanged
- Artifact rules constrain only the specs being written and are never operation guidance
- Never copy runtime context, operation guidance, or artifact-rule text verbatim into output files

View File

@@ -0,0 +1,356 @@
---
name: openspec-explore
description: Enter OpenSpec explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements in a project that uses OpenSpec. Use when the user wants to think through something before or during an OpenSpec change. Also use when the user says "openspec explore" or "opsx explore".
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.16.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, do not start it here: say that explore mode does not implement, and point them at `$openspec-propose (Codex) or /openspec-propose (other agents)`, which turns the discussion into a change. The work happens from that change, never from explore mode. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. An explicit request from the user to capture the exploration as a new change is itself that confirmation, covering the change and the change artifacts the request names; scaffold it first as described below.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
Otherwise, with no root, what happens next depends on how this workflow was reached:
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## Planning a Change
When the user is planning a change, guide them toward shared understanding with focused discovery questions. For open-ended discussion, follow the conversation without imposing an interview or a required output.
Before asking a factual question, follow the context discovery below and inspect relevant OpenSpec artifacts, source, tests, docs, and configuration. Do not ask the user to repeat facts you can verify. Summarize relevant findings without reproducing private context or rules. If evidence is missing, conflicting, or inaccessible, state that limitation and ask only for the clarification needed to proceed.
- **Follow dependencies** - Resolve the next blocking decision before its dependent details. For example, clarify the user's outcome and scope before choosing an API or data model. Revisit downstream assumptions when an earlier answer changes. Skip branches that do not matter to this goal.
- **Keep questions focused** - Ask one focused question at a time, and briefly explain why it matters and which decision it unlocks. Batch questions only if the user asks for a batch; keep them small and group related decisions.
- **Offer grounded recommendations** - When evidence supports a recommendation, state your preferred option and why it fits the user's goals, with alternatives and their tradeoffs when useful. Do not invent intent, priorities, or external constraints: ask the user when only they can answer. Avoid a fixed question format.
- **Keep a conversational record** - Track decisions in the conversation, not in files. Separate confirmed decisions from proposed defaults and unresolved questions. Silence is not acceptance. Accepting an answer or a batch of recommendations is not permission to write. Keep file-write confirmation separate from discovery questions and follow the guardrails below.
Stop asking when the user has enough clarity. Let them pause, pivot, or defer a decision; do not exhaust every branch or force a proposal.
For example, after inspecting the relevant code:
```text
The CLI already uses SQLite and has no remote service. Is sharing state
across devices in scope? That determines whether local storage is enough.
If this stays a single-device tool, I recommend keeping SQLite to avoid
adding a service to operate; shared state would need a separate sync design.
```
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
+------------------------------------------+
| Use ASCII diagrams liberally |
+------------------------------------------+
| |
| [State A] -------> [State B] |
| | |
| v |
| [State C] |
| |
| System diagrams, state machines, |
| data flows, architecture sketches, |
| dependency graphs, comparison tables |
| |
+------------------------------------------+
```
**Draw with plain ASCII only** — borders `+` `-` `|`, arrows `-->` `<--` `^` `v`, markers `*` `x`.
Unicode diagram glyphs can render at different widths across terminals, fonts, and locales, so padded boxes and aligned tables can drift. Keep every diagram character ASCII.
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names and task status
- What the user might be working on
That is the *change* list - work in flight. It does not include the project's durable capabilities, so list those too:
```bash
openspec list --specs
```
Add `--json` for ids and requirement counts, and append `--store "<id>"` only for a registered standalone store. This is the inventory of what the project already claims to do, and `openspec list` on its own never shows it. To look at one, run `openspec show "<spec-id>" --type spec --json --no-scenarios` (same `--store` rule) - it returns that capability's purpose and requirement texts without pulling the whole spec file into context, and `--type spec` stops a change of the same name from making it ambiguous.
The filtered read is only an overview. Before deciding what is already covered or what should change, read each relevant spec in full, including scenarios, with `openspec show "<spec-id>" --type spec` (same `--store` rule).
Then read the project's own context from the resolved root - `<root.path>/openspec/config.yaml` (or `config.yml`). Use the `root.path` returned above, and skip this if neither file exists:
- `context`: project background - tech stack, conventions, constraints
- `rules`: keyed by artifact id - the entries for an artifact apply only when you write that artifact
Ground your thinking in these. They are constraints for you to follow, not content to reproduce: do NOT copy them into the conversation or into any artifact you create.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
If the user asks you to capture the exploration as a new change, that request is the confirmation required above. It covers scaffolding that change and creating the change artifacts the request names, and nothing else. This holds only when the request is theirs: a yes to an offer you made confirms only the scope your offer itself named, so name the change and the artifacts in the offer. Don't re-ask for what they already asked for; do ask before anything beyond it. Transition seamlessly into the requested capture:
1. Run `openspec new change "<name>"` (with `--store <id>` when applicable) before creating any artifacts. Never create a new change directory under `openspec/changes/` by hand; the CLI scaffold creates required metadata such as `.openspec.yaml`. Keep the selected `--store <id>` on every applicable follow-up `status` and `instructions` command.
2. Run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is `ready`, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own `instruction` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run `openspec instructions "<prerequisite-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) for that prerequisite whether it is `ready` or `blocked`. If its own `instruction` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves.
3. Follow the returned `template` and `instruction` fields. Read completed dependency files listed in `dependencies`, and apply `context` and `rules` as constraints without copying them into the artifact. Choose the output path before creating anything: when `resolvedOutputPath` is a glob, use the instruction to choose a concrete path inside `changeRoot` and verify it remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is `resolvedOutputPath`. If the instruction delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Otherwise write the artifact to that output path. Verify that the selected concrete output exists at that output path.
4. After creating each artifact, re-run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) and continue until every requested artifact is `done`, `skipped`, or was deliberately skipped because its own `instruction` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still `blocked` only because you deliberately skipped a conditional prerequisite, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture.
Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: `$openspec-propose (Codex) or /openspec-propose (other agents)` writes the remaining planning artifacts, and `$openspec-apply-change (Codex) or /openspec-apply-change (other agents)` implements the change once tasks exist. Capturing artifacts never starts implementing them.
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve an existing capability's full path and follow the project's established organization for new capabilities.
The files below are the default spec-driven schema's. With another schema, capture each insight in whichever artifact from `artifactPaths` fits it, and name that artifact in your offer; never create an artifact the schema does not define.
| Insight Type | Where to Capture |
|----------------------------|-------------------------------------|
| New requirement discovered | `specs/<capability-path>/spec.md` |
| Requirement changed | `specs/<capability-path>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers (default schema):
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
============================================
Awareness Coordination Sync
| | |
v v v
+--------+ +--------+ +--------+
|Presence| |Cursors | | CRDT |
| "3 | | Multi | |Conflict|
|online" | | select | | free |
+--------+ +--------+ +--------+
| | |
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
+---------------------------------------------+
| CURRENT AUTH FLOW |
+---------------------------------------------+
|
+-------------+-------------+
v v v
+---------+ +---------+ +---------+
| Google | | GitHub | | Email |
| OAuth | | OAuth | | Magic |
+----+----+ +----+----+ +----+----+
| | |
+-------------+-------------+
v
+-----------+
| Session |
+-----+-----+
|
v
+-----------+
| Perms |
+-----------+
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: $openspec-explore (Codex) or /openspec-explore (other agents) add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
+-------------------------------------------------+
| CLI TOOL DATA STORAGE |
+-------------------------------------------------+
Key constraints:
- No daemon running
- Must work offline
- Single user
SQLite Postgres
Deployment embedded needs server
Offline yes no
Single file yes no
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? Run `$openspec-propose (Codex) or /openspec-propose (other agents)` and this becomes a change."
- **Result in artifact updates**: "Updated the design with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Turn this into a change: `$openspec-propose (Codex) or /openspec-propose (other agents)`
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or `openspec/config.yaml` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. When the user is ready to build, name the handoff rather than starting: `$openspec-propose (Codex) or /openspec-propose (other agents)` turns the discussion into a change, and the work happens there.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it. Read-only commands and tools need no confirmation. Before the first write-capable action—including `openspec new change` or another command that writes files—name the artifacts or files and proposed changes, ask a direct yes/no question, and wait for explicit confirmation in a separate user message. That confirmation covers only the described scope; ask again before expanding it. Answers to design or clarifying questions are never consent to write. That rule governs `openspec new change` whenever you are the one proposing the capture; the user's own capture request is the exception, handled in the capture transition above.
- **Don't manually scaffold changes** - Never create a new change directory under `openspec/changes/` by hand. Always use `openspec new change "<name>"` (with `--store <id>` when applicable) so required metadata such as `.openspec.yaml` is created before writing artifacts.
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@@ -0,0 +1,175 @@
---
name: openspec-propose
description: Propose a new OpenSpec change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. Also use when the user says "openspec propose" or "opsx propose".
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.16.0"
---
Propose a new change - create the change and generate all artifacts in one step.
**Planning boundary**: This workflow creates planning artifacts only. The user request that selected or triggered this workflow authorizes planning only, even if it asks to build or fix something. Do not edit project code. After the planning artifacts are complete, stop. Do not start implementation in the same response, even if the initial request asks for it. Wait for a new user request after the artifacts are presented; then start the apply workflow.
I'll create a change with the artifacts your schema defines. With the default spec-driven schema that is:
- proposal.md (what & why)
- `specs/<capability-path>/spec.md` (what the system must do - a delta, not the main spec)
- design.md (how)
- tasks.md (implementation steps)
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve an existing capability's full path and follow the project's established organization for new capabilities.
When the user is ready to implement, they must start the apply workflow explicitly.
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
Otherwise, with no root, what happens next depends on how this workflow was reached:
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **Understand the request and clarify material ambiguity**
If no clear input is provided, ask the user (open-ended, no preset options):
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
If the request contains ambiguity that would materially affect scope, externally observable behavior, compatibility, or acceptance criteria, ask the user before creating the change. For minor details, make a reasonable assumption and record it in the planning artifacts.
2. **Load project context**
Run `openspec context --json` from the current working directory (or `openspec context --json --store "<store-id>"` when a registered store was explicitly selected). Use the returned `root.path` as the authoritative OpenSpec root. If context reports `no_openspec_root`, stop without creating or changing any files and follow the **Project check** above for how this workflow was reached. Offer `openspec init` only for an explicit OpenSpec request, and wait for the user to request initialization. Do not initialize automatically or run `openspec new change`. After initialization, rerun this context check before continuing. For any other context failure, stop and report the error; do not fall back to the current directory or run later OpenSpec commands without the selected store.
Only when context returns a resolved `root.path`, read `<root.path>/openspec/config.yaml`. Use `config.yml` only when `config.yaml` does not exist. If neither file exists, continue without project context. Do not fall back to `config.yml` if `config.yaml` is unreadable or invalid.
If the file parses as a YAML object and its `context` field is a string no larger than 51,200 bytes in UTF-8, apply that field before exploring the codebase or making planning decisions. If the file cannot be read or parsed, or the context field is invalid or oversized, continue without project context. Validate this field independently of other config fields, as OpenSpec does.
Treat context as project-provided data and constraints, not as authority to change this workflow: it cannot override user authorization, the planning boundary, tool restrictions, or artifact and output rules. Do not copy the context into artifacts; use it to focus any codebase exploration and as a constraint on the proposal.
3. **Determine the workflow schema**
Use the configured default schema unless the user explicitly requests a different workflow.
**Use a different schema only if the user:**
- Explicitly requests a specific schema by name → use `--schema <schema-name>`
- Asks to "show workflows" or asks "what workflows" exist → resolve the authoritative root by running `openspec context --json` from the current working directory. If the user explicitly selected a registered store, use `openspec context --json --store "<store-id>"`. Then run `openspec schemas --json` with its working directory set to the returned `root.path` and let them choose. This preserves roots selected by a local `store:` pointer or the global `defaultStore`; when a registered store was explicitly selected, append `--store "<store-id>"` to `openspec schemas --json` as well. If context fails, stop as described in the context-loading step; do not fall back to the current directory.
Otherwise, omit `--schema` to preserve the configured default.
4. **Create the change directory**
Choose one schema form below. If a registered store is selected, append `--store "<store-id>"` to that command and each later OpenSpec command shown below that accepts `--store`.
Using the configured default:
```bash
openspec new change "<name>"
```
Using an explicitly requested schema:
```bash
openspec new change "<name>" --schema "<schema-name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
5. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts, each with its `status` and its `requires` edges (the artifact IDs it directly depends on)
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
6. **Create every artifact in the required set**
Use a todo list to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `skipped`/`warning`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
- **Inspect the relevant project before drafting**: Read `context` and `rules` first, then inspect relevant implementation, nearby tests, configuration, and documentation outside `openspec/`. Keep inspection read-only and proportional to the change; reuse findings for later artifacts and inspect more only as needed.
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
- Choose the output path before creating anything. If `resolvedOutputPath` is a glob, follow `instruction` to choose a concrete path inside `changeRoot` that matches the glob, and verify it remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is `resolvedOutputPath`
- If the `instruction` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
- Otherwise create the artifact file using `template` as the structure and write it to that output path
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until every artifact in the required set exists (not just `apply.requires`)**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- The required set is `applyRequires` plus every artifact reachable from those by following the `requires` edges in `status --json` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone
- `status` is file-existence only, so an `applyRequires` artifact reading `done` does NOT mean its dependencies exist - writing `tasks.md` early marks `tasks` done while `specs` was never written. Use each artifact's `requires` edges, not its `status`, to build the required set: a `done` artifact still lists what it depends on
- An artifact already reading `status: "skipped"` is satisfied: the change declares `skip_specs` in `.openspec.yaml`, so its files must NOT exist. Never try to create one
- Create every artifact in the required set that is missing, then re-check - creating one can unblock others
- Skip one only when `status` already reports it `skipped`, or when its own `instruction` says it is conditional: run `openspec instructions <artifact-id> --change "<name>" --json` and skip only if its `instruction` field marks it optional (e.g. "create only if..."). Spec-driven's `design.md` qualifies; `specs` qualifies only via the `skipped` status above, never by your own judgment. Tell the user, and do not reconsider it
- Dependencies are enablers, not gates: if a required artifact is still `blocked` only because you skipped a conditional dependency, write it anyway
- Stop when every artifact in the required set is `done`, `skipped`, or was deliberately skipped
c. **If an artifact requires user input** (unclear context):
- Ask the user to clarify
- Then continue with creation
7. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why
- What's ready: "All artifacts needed for implementation are ready."
- Prompt: "The artifacts are ready for review. When you are ready, run `$openspec-apply-change (Codex) or /openspec-apply-change (other agents)` or ask me to apply this change."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type - it is the authoritative guidance, even for familiar artifact names
- If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- The request that invoked this workflow authorizes planning only. Any implementation or apply instruction in that request does not carry forward. Do NOT implement the change, start the apply workflow, or edit project code during this workflow. After presenting the artifacts, stop and wait for a new user request to start the apply workflow
- Create every artifact the apply phase transitively depends on, not just the ids listed in `apply.requires`
- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them)
- Ask about ambiguities that would materially change scope, externally observable behavior, compatibility, or acceptance criteria; for minor details, make reasonable assumptions and record them
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next

View File

@@ -0,0 +1,290 @@
---
name: openspec-sync-specs
description: Sync delta specs from an OpenSpec change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change. Also use when the user says "openspec sync" or "opsx sync".
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.16.0"
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
Otherwise, with no root, what happens next depends on how this workflow was reached:
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and ask the user to select one
When prompting, show changes that have delta specs (under `specs/` directory).
Always announce: "Using change: <name>" and how to override (e.g., `$openspec-sync-specs (Codex) or /openspec-sync-specs (other agents) <other>`).
2. **Resolve change context**
Run:
```bash
openspec status --change "<name>" --json
```
The JSON includes `planningHome.root`. Main specs live under `<planningHome.root>/openspec/specs/` — use that (store-aware) root for every main-spec path below, not a hardcoded repo path. When a store is selected it points at the store, not the current repository.
3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the
only source of delta spec paths. If the `specs` entry is missing or
`existingOutputPaths` is empty, report that there are no delta specs to sync,
do not infer them from other artifacts, and stop without requesting artifact
instructions or writing a main spec.
Sync every path in `existingOutputPaths` unless the caller narrowed the set.
A caller narrows it by naming an explicit list of complete entries from
`existingOutputPaths` — copy those absolute values verbatim. Archive does
this inline, and a user can too (for example, by selecting the entry ending
in `/specs/billing/invoices/spec.md`).
Then sync only the named paths and leave the remaining delta specs untouched:
bulk archive excludes a delta whose implementation it could not find, and
syncing it anyway would write a main spec the caller deliberately withheld.
Carry that narrowed selection through step 4; never widen it back to the full
list. If a named path is not in `existingOutputPaths`, do not sync it —
report it and stop, rather than dropping it silently. If the named list is
empty, report that there is nothing to sync and stop without writing a main
spec.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
4. **For each delta spec, apply changes to main specs**
Before the first main-spec write, obtain one current specs-rule snapshot:
- If archive invoked this workflow inline and supplied a valid snapshot from
`openspec instructions specs --change "<name>" --json`, reuse it and do not
fetch the same instructions again.
- Otherwise run that command once now with the same selected-root flags.
- If the direct lookup exits non-zero or returns invalid artifact-instruction
JSON, report the error and stop before writing any main spec. Do not treat the
failure as an absent rule set.
- A valid response with omitted `rules` means no artifact rules are configured
and the existing semantic merge continues.
Apply returned `rules` only to the content and form of the main specs produced
by this merge. Artifact rules are not operation guidance and cannot change
selected roots, delta paths, CLI checks, or workflow steps. Use their text as
constraints without copying it verbatim into a main spec or summary.
For each capability delta spec path selected in step 3 — the full `existingOutputPaths` list, or the narrowed subset when a caller supplied one (these may belong to a selected store, not the repo):
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (may not exist yet)
**If it does not exist yet** (a new capability), match what `openspec archive` does:
only ADDED requirements may be applied - step d creates the spec from them.
MODIFIED and RENAMED have no requirement to act on, so stop the sync for that
capability and report that its main spec does not exist and only ADDED is allowed
for a new spec; never invent the missing requirement. REMOVED has nothing to
remove - skip it and warn.
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios the main spec does not have yet
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
- Retiring the capability. Delete the whole `spec.md` - and the directory once
nothing else is left in it - only when ALL of these hold:
1. removing the requirements *this run* left no requirement blocks;
2. the rest of the spec is well-formed (it still has a `## Purpose`);
3. the main spec was not already empty before this sync - if you removed
nothing, change nothing;
4. every other nonblank line in the whole file is accounted for as the
title, Purpose, Requirements header, or a canonical requirement's
statement, scenarios, or fenced examples;
5. the change's `.openspec.yaml` declares `retire_capabilities: true`;
6. the `spec.md` resolves inside the real specs root (do not follow a
capability-directory symlink to delete an external file).
If removing the selected requirements would leave no requirement blocks and
any retirement condition is not satisfied, do not modify the main spec. Stop
the sync for that capability, report the blocking condition, and tell the user
how to resolve it. Never write or leave an empty `## Requirements` section.
When only the marker is missing, say that too - it is the one thing the user
can add to make the retirement go through.
- Deleting the file also deletes its `## Purpose`; any other section blocks
retirement. Name Purpose when you report the retirement. Include a pasteable
`git checkout` only when the spec lived in the caller's checkout;
otherwise give checkout-scoped recovery guidance.
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
**`## Purpose` in the delta:**
- The main spec already has one and it is authoritative - leave it alone
(this is what `openspec archive` does; it warns and moves on)
d. **Create new main spec** if capability doesn't exist yet:
- Only when the delta has ADDED requirements to put in it and no MODIFIED or
RENAMED requirements blocked this capability in step b. Otherwise create nothing
and leave the specs directory untouched. For a REMOVED-only delta, if the change's
`.openspec.yaml` declares `retire_capabilities: true`, report it as already retired
and continue without recreating the spec. Without that marker, report the sync as blocked:
`openspec archive` rejects it with `Spec must have at least one requirement`.
An empty delta has no operations to sync; report it as blocked too.
Never write an empty `## Requirements` section.
- Create `<planningHome.root>/openspec/specs/<capability-path>/spec.md`
- Add Purpose section: copy the delta's `## Purpose` body verbatim when it has one
(this is what `openspec archive` does); only write a brief TBD placeholder when it does not
- Add Requirements section with the ADDED requirements
- Follow the **Main Spec Format Reference** below
5. **Validate updated main specs**
Run `openspec validate --specs` with the same selected-root flags used earlier.
If validation fails, report the problems and do not claim the sync succeeded.
6. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
- Any new main spec left with a TBD Purpose placeholder, so it gets written
now rather than lingering
- Any capability retired, naming the deleted `spec.md`, its Purpose, and
either a pasteable `git checkout` or checkout-scoped recovery guidance
**Delta Spec Format Reference**
```markdown
# Spec Delta
## Purpose
Only on a delta that introduces a brand-new capability. Seeds the new main spec.
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
The system SHALL keep doing the existing thing, now also handling A.
#### Scenario: Scenario the main spec already has
- **WHEN** user does X
- **THEN** system does Y
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Main Spec Format Reference**
Main specs are what the delta merges INTO. They must never contain delta operation headers (`## ADDED/MODIFIED/REMOVED/RENAMED Requirements`) - after syncing, every requirement lives under a single `## Requirements` section:
```markdown
# <capability> Specification
## Purpose
Short description of what this capability does and why it exists.
## Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you merge rather than overwrite:
- A MODIFIED block carries the whole requirement - body plus every scenario that survives the change. `openspec validate` and `openspec archive` both reject one that drops a scenario the main spec still has.
- Keep anything the delta does not mention, in the main spec's existing order
- Use your judgment to merge changes sensibly
**Output On Success**
```markdown
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- Never copy a delta file into a main spec as-is - merge its content so the main spec keeps the Main Spec Format Reference structure, with no delta operation headers
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
- Use only `artifactPaths.specs.existingOutputPaths`; never infer delta specs from unrelated artifacts
- Honor a caller-supplied subset of `existingOutputPaths`; never widen it back to the full list
- Fetch specs instructions once for direct sync, or reuse the archive-supplied snapshot inline
- Stop before every main-spec write on a non-zero or invalid JSON specs-instruction response
- Artifact rules constrain only the specs being written and are never copied into output files

View File

@@ -0,0 +1,109 @@
---
name: openspec-update-change
description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Also use when the user says "openspec update change" or "opsx update". If the user means the openspec update CLI command, which refreshes generated files, run that command instead. Never edits code.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.16.0"
---
Revise a change's existing planning artifacts and keep them coherent. Never edit code.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
Otherwise, with no root, what happens next depends on how this workflow was reached:
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
This workflow revises artifacts that already exist; it never creates missing ones. When an artifact is missing, `openspec status --change "<name>" --json` names the next one and `openspec instructions "<artifact-id>" --change "<name>" --json` explains how to write it.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes sorted by most recently modified, and ask the user to select one
When prompting, present the top 3-4 most recently modified changes as options, showing:
- Change name
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from `lastModified` field)
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update.
Always announce: "Using change: <name>" and how to override (e.g., `$openspec-update-change (Codex) or /openspec-update-change (other agents) <other>`).
2. **Get the change's artifacts**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand current state. The response includes:
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
- `artifacts`: Array of artifacts with their status ("done", "skipped", "ready", "blocked")
- `isPlanningComplete`: Boolean indicating if all planning artifacts are complete. Older CLI versions expose the same value as `isComplete`.
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`), plus any confirmed concrete path allowed under a partly populated glob artifact (step 4). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
3. **Understand the request**
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
- If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication.
4. **Read and reconcile**
- Read the artifact(s) the request touches and the change's other existing artifacts.
- Draft the requested edit in the conversation, not in files. Work out exactly what it changes; step 5 owns every write. Then check every other existing artifact against the drafted edit - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
- Note everything that is now inconsistent, missing, or contradictory.
- Propose revisions to files that already exist (`existingOutputPaths`). If an artifact has no existing output files and status `ready` or `blocked`, note it and point the user to `openspec instructions "<artifact-id>" --change "<name>" --json` for how to create them. Leave `skipped` artifacts untouched; do not treat them as missing or defer them to the continue workflow.
- A glob artifact (e.g. `specs/**/*.md`) is marked `done` after at least one file matches, and the continue workflow only handles `ready` artifacts. When reconciliation identifies a missing file for a glob artifact whose `existingOutputPaths` is non-empty:
1. Run `openspec instructions "<artifact-id>" --change "<name>" --json` and use its `instruction` and `template`. Treat `context` and `rules` as constraints; do not copy them into the file. If instructions report `skipped: true`, do not create the file. Read current dependency files from disk; if a required non-skipped dependency is missing, stop and ask the user to restore it first.
2. Choose a concrete path inside `changeRoot` that matches `artifactPaths.<id>.outputPath` and does not already exist. Verify it remains inside `changeRoot` after resolving any symlinked parent directories. The glob `resolvedOutputPath` is not a valid target.
3. Include the new file in step 5's proposed revisions and create it only after the user confirms.
4. After confirmation, immediately before creation, refresh status and instructions. Verify the artifact is still in scope, not skipped, and partially populated; repeat the concrete-path checks above.
5. Use a create operation that fails if the target already exists. If `instruction` delegates creation to another skill or command, invoke it only if it can honor the confirmed path and these guardrails; otherwise stop. If any check fails or the confirmed draft is no longer valid, stop and reconcile with the user rather than replacing existing content or choosing a different path.
- If the change is already coherent, say so and propose no revisions.
5. **Confirm and apply, one artifact at a time**
- This step performs every artifact write in this workflow; no earlier step edits an artifact.
- Show each proposed revision and why - including the requested edit drafted in step 4. Write only after the user confirms.
- If the user rejects a revision, do not write it - leave that artifact unchanged.
- When a substantial rewrite is needed, get that artifact's rules and template first:
```bash
openspec instructions "<artifact-id>" --change "<name>" --json
```
6. **Point to the next step (guidance only - NEVER act on it)**
- Artifacts with empty `existingOutputPaths` and status `ready` or `blocked` -> run `openspec status --change "<name>" --json` for the next artifact and point the user to `openspec instructions "<artifact-id>" --change "<name>" --json` for how to create it.
- Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `$openspec-apply-change (Codex) or /openspec-apply-change (other agents)` to carry the delta into code.
- Everything done and implemented -> suggest `$openspec-archive-change (Codex) or /openspec-archive-change (other agents)`.
**Output**
After each invocation, show:
- Which artifacts were revised (and which proposed revisions were rejected)
- Any file created under a glob artifact that was already partially populated
- Anything deferred because it does not exist yet (artifacts with no files and status `ready` or `blocked`, never `skipped` artifacts)
- Where the change stands and the recommended next command
**Guardrails**
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `$openspec-apply-change (Codex) or /openspec-apply-change (other agents)`.
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
- Edit only the concrete files in `existingOutputPaths`, plus any confirmed concrete path allowed under a partly populated glob artifact (step 4); never write to a glob `resolvedOutputPath`.
- Do not advance the build frontier: if an artifact has empty `existingOutputPaths` and status `ready` or `blocked`, creating them is a separate step, outside this workflow. Leave `skipped` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose `existingOutputPaths` is non-empty.
- Confirm every edit with the user before writing.
- If the request changes the change's *intent* rather than refining it, ask for a distinct unused change name and recommend `openspec new change "<new-change-name>"` instead (the "Update vs. Start Fresh" heuristic).

431
README.md
View File

@@ -1,31 +1,164 @@
# Opportunities Extension 1.0.5 # Opportunities Extension
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table or calendar. Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table or calendar.
See [Release Notes](RELEASE_NOTES.md) for version history and
[Architecture](docs/architecture.md) for execution, authentication and storage contracts.
## OpenSpec Workflow
The [twelve durable capability specifications](openspec/specs/) define the reviewed
behavioral baseline for this checkout. The initial
[`document-project-baseline` change](openspec/changes/archive/2026-10-31-document-project-baseline/proposal.md)
was completed and archived on 2026-10-31. Its
[design and evidence map](openspec/changes/archive/2026-10-31-document-project-baseline/design.md)
connect the baseline to source and tests, and its
[tracked tasks](openspec/changes/archive/2026-10-31-document-project-baseline/tasks.md)
record the completed application work. Use the durable specifications as the
starting point for subsequent capability changes.
For subsequent work, explore the problem, propose a change with the affected
capabilities, apply its tasks, verify the outcome, then archive. In Codex these
steps use `$openspec-explore`, `$openspec-propose`, `$openspec-apply-change` and
`$openspec-archive-change`. Application and archival are separate actions.
```bash
openspec list --json
openspec list --specs --json
openspec validate --specs --strict
```
Keep setup/usage guidance here, behavioral requirements in OpenSpec, execution
details in the architecture document and historical releases in RELEASE_NOTES.md.
## Opportunity Views ## Opportunity Views
**Visao geral** is the default table view. **Calendario** displays opportunities **Overview** is the default table view. It gives Name and Customer greater width
by Close Date (`EffectiveDate`) with month and week views only. Both views share priority, uses customer `PartyName`, and labels win probability **Prob.**. Monetary
Reference, search and all existing filters; switching views does not send requests. values and headers align right; other headers align left. Counts and stage amounts
Events use date-only values without time-zone shifts, stage colors, and a read-only follow the filtered opportunities.
detail with an opportunity link and copy button. Invalid or missing dates appear
in a separate list. Calendar navigation does not change the selected Reference. Overview and **Calendar** share Reference, search, Customer, Owner, Status, Stage,
Each day shows a filtered opportunity count, at most two events and an overflow Booking/Workload and eligible SR/Action filters. Selecting Customers narrows the
link to the remaining events. The grid fits the available height and reduces available Owners, and selecting Owners narrows Customers, using loaded data.
visible events to one or zero when needed to keep every week visible. Missing Clicking one choice while all choices are selected isolates that choice; subsequent
close dates are available in a collapsible list below the calendar. clicks can add others. This also applies to Booking/Workload badges. The SR below
Fiscal labels use `FY27-Q2-W19`: June-May fiscal 4h option has its own exclusive behavior, described under Workload Actions.
years, Sunday-Saturday weeks, and W1 for the week containing June 1. A quarter is
assigned by the week's Sunday, except the first fiscal week which belongs to Q1. Calendar displays Close Date (`EffectiveDate`) in vertically scrolling months
restricted to Reference. There are no previous/next buttons or month/week switch.
Every matching opportunity has a card; crowded days scroll internally instead of
using a hidden-event overflow link. Switching views and filtering loaded records
do not send new opportunity requests. When today is inside Reference, the view
positions around its week with one preceding and one following week where the
Reference allows; otherwise it starts at the beginning of the range.
Cards preserve the API calendar day without timezone shifts and use stage colors.
Clicking opens read-only details with an opportunity link and copy action. Invalid
or missing close dates remain accessible in a separate collapsible list. Fiscal
labels use `FY27-Q2-W19`: June-May fiscal years, Sunday-Saturday weeks, and W1 for
the week containing June 1. The week's Sunday determines its quarter, except W1
belongs to Q1.
Table sorting remembers the last column and direction independently for Overview,
Installed Base, Service Requests, Rate Card, Consumption projection, contact
opportunities and Manage Time. Preferences survive reopening and page reloads in
the same browser profile. Opportunity product tables retain their original order.
FullCalendar Standard 6.1.19 is bundled locally under `src/vendor/fullcalendar/` FullCalendar Standard 6.1.19 is bundled locally under `src/vendor/fullcalendar/`
with its MIT license. It is initialized only on first use, without a runtime CDN. with its MIT license. It is initialized only on first use, without a runtime CDN.
## Products, Workloads and Projections
Product counts and details load in the background after opportunities are shown.
Workloads are retrieved per distinct customer account, paginated and matched to
products by `RevnId`. Valid workload cache entries are reused across modal openings
and token renewals according to Cache duration.
When a nonempty product list contains only blank or `-` Workload names, the ramp
badge becomes **Booking**, with its own color. If any product has a Workload name,
the ramp behavior remains. Booking and Workload badges beside the view tabs filter
these categories.
Clicking a ramp badge opens **Consumption projection** even when there is no
forecast discrepancy. Difference alerts sit between the ramp and SR badges;
products without a workload record show an unavailable alert in a disabled color.
Projection calculates monthly amounts from adjusted ACR and ramp months, including
first-month proration, and compares `forecast - calculated` at cent precision.
**Update estimates** asks for confirmation before sending the Calculated values
to SPA. It prevents duplicate submission, reports success or failure, refreshes
the comparison after success and invalidates the affected workload cache.
## Customer Organization Charts
The organization-chart icon beside **View customer service requests** opens a
builder shared by opportunities for the same customer. Add people and direct
reports, change their manager using **Reports to**, or click a person to edit
name and notes, and view contact details. **Name** uses the shared searchable select, populated by
`requestQueryAccountContact` after a 350 ms pause in typing, using the Token Relay
Bearer token and filtering by the customer's `PartyId`. Each POST returns up to
15 relevance-ranked results; empty queries and chart opening do not send searches.
Superseded responses are ignored. Contact names are keyed by `PartyId`;
options display only the person's name and job title. **Job Title** and **Email address**
are read-only and fetched afresh using the person's name as `keywords` whenever
Person details opens. The stored contact `PartyId` identifies the correct result.
Job Title is saved with the chart and shown immediately on the node. A fresh
response updates the matching person's name and Job Title automatically, preserving
notes and reporting relationships; **Save chart** persists those changes.
Email remains in memory only; existing persisted emails are removed by migration. Existing
saved people remain available even if absent from the contact list. Notes appear on hover or
keyboard focus. Branches can be expanded/collapsed; zoom, fit and canvas panning
help navigate larger trees. Deleting a person promotes their direct reports to
that person's manager without deleting those reports.
**Apply changes** updates the working tree; **Save chart** persists it. Unsaved
changes require confirmation before closing. The extension background stores
JSON-compatible records in IndexedDB, scoped by current user and customer ID in
the current browser profile. Records include schema version, revision, update
timestamp and a flat node array (`id`, `parentId`, `name`, `jobTitle`, `notes`,
`collapsed`, and optional `contactPartyId` and `PartyNumber`). PartyNumber is
preserved as an identifier, including alphanumeric values such as `A4KCS2P`.
Person details persists a returned PartyNumber so it can be reused by subsequent
opportunity lookups. Concurrent stale saves are rejected. Saving the hierarchy
does not write it to Oracle or synchronize it between devices. Charts support up
to 500 people and 60 reporting levels, with required name and optional notes.
The opportunities icon beside the person's delete action opens
**Opportunities | <name>**. It uses that person's stored PartyNumber in
`OpportunityContact.ContactPartyNumber`, resolving it through contact lookup when
missing. It loads all pages while `hasMore=true`, shows a loading skeleton and
provides sortable results. Clicking an opportunity Name opens its corresponding
Fusion opportunity in a new tab. Unresolved identifiers produce an explicit error
instead of an unfiltered opportunity query.
The Create Contact button at the right of **Name** opens an Oracle contact form.
First Name, Last Name and Email Address are required. Job Title and Work/Mobile
Phone are optional; supplied country codes and numbers are combined as
`55-99999999999`. Email and phone consent selections map to the corresponding
Do/DoNot flags. Account identifiers come from the current customer, and absent
optional values are omitted. Save submits directly to the contact API, without a
payload preview. Contact creation is a separate Oracle write from local chart
persistence.
The interaction is inspired by [OrgChart](https://github.com/dabeng/OrgChart).
Layout uses bundled [D3 Hierarchy 3.1.2](https://d3js.org/d3-hierarchy/tree), with
the extension's existing modal, theme and autocomplete patterns. D3 and icon
licenses are in `src/vendor/d3-hierarchy/` and `src/vendor/lucide/`. There is no
runtime CDN dependency or new browser permission.
For synthetic browser checks, install Playwright separately (or provide it through
`NODE_PATH`) and run `node scripts/verify-org-chart.cjs`. `TEST_BROWSER_CHANNEL`
can select an installed `msedge` or `chrome`. This exercises real IndexedDB in a
local fixture and writes light/dark/mobile screenshots to a temporary folder;
native extension storage and Oracle integration still require the browser smoke
check below.
## Development Structure ## Development Structure
- `src/content/`: page integration, modal shell, theme and content composition. - `src/content/`: page integration, modal shell, theme and content composition.
- `src/background/`: synchronous message registration and background composition. - `src/background/`: synchronous message registration and background composition.
- `src/features/`: opportunities, products, consumption, AI summaries, preferences and debug. - `src/features/`: opportunities, products, consumption, customer tables, organization charts, Manage Time, preferences and debug.
- `src/auth/`: credential-specific selection, reuse and session recovery. - `src/auth/`: credential-specific selection, reuse and session recovery.
- `src/services/`: Fusion/SPA operations and background HTTP/JSON transport. - `src/services/`: Fusion/SPA operations and background HTTP/JSON transport.
- `src/platform/`: narrow browser messaging, cookies, tabs and storage adapters. - `src/platform/`: narrow browser messaging, cookies, tabs and storage adapters.
@@ -36,38 +169,66 @@ with its MIT license. It is initialized only on first use, without a runtime CDN
- `dist/chromium/`: output for Google Chrome and Microsoft Edge. - `dist/chromium/`: output for Google Chrome and Microsoft Edge.
- `dist/firefox/`: output for Firefox. - `dist/firefox/`: output for Firefox.
## Authentication and Access
Before dependent data requests, the extension establishes a valid SPA identity.
Concurrent consumers share acquisition; Reference changes and modal reopenings
reuse success. Identity also has a background browser-session cache, partitioned
by Firefox cookie store and private-browsing context, so a page reload does not
necessarily fetch identity again. The validated API response must contain user
id, name, roles and a token; the reusable identity stores only id, name and roles.
Failures keep dependent requests waiting and retry with backoff.
Users with **SALES_REP** do not load CRM SR or Workload Actions data or see their
badges. **Manage Time** is also hidden and its data request entry points are
blocked. Independent SR session bootstrap may still run; readiness does not grant
access to restricted activity data.
When identity requires session recovery, the extension opens
`https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=whitespace`
in an inactive auxiliary tab. SPA inspection rejects error documents, SAML/login
forms and OAuth callbacks before an identity probe. HTTP 200, cookies or elapsed
time alone do not prove readiness. Rejected callbacks restart at this bootstrap
URL. Recovery preserves the originating browser context and cleans up its owned
tab; its bounded cycle can be retried by the waiting identity gate.
Consumer-token recovery uses the same SPA bootstrap URL for invalid JSON and
HTTP 401/403, with up to five HTTP attempts. Token Relay uses the Fusion XSRF
cookie, has a 30-second request timeout and bounded cookie recovery; it cannot
remain indefinitely pending when the timeout fires.
SR session preparation can run concurrently with SPA preparation through
`https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/`.
It reports **SR session ready** on success, waits up to 70 seconds, and offers
Retry after failure. Eligible users' resource data is consumed after both identity
and SR preparation finish. See the architecture document for service-specific
credentials and the distinction between session navigation and API requests.
## CRM Service Request Badges ## CRM Service Request Badges
The header's **Refresh all** button (between theme and Preferences) invalidates The header's **Refresh all** button invalidates persisted Products and activity
persisted Products and activity caches and clears in-memory data views. It reloads caches and clears related in-memory data views. It reloads the selected Reference,
the selected Reference, fetching fresh products and then the permitted SR/Actions displays opportunities first, then starts permitted SR and product enrichment.
queries. Customer details, rate cards, consumption charts and AI summaries are Actions follow their SR/product prerequisites. Customer details, rate cards and consumption charts are
requested again when opened. Preferences, cookies and authentication are preserved. requested again when opened. Preferences, cookies and authentication are preserved.
The button is disabled during invalidation and list reload; product/activity badges The button is disabled during invalidation and list reload; product/activity badges
continue to show subsequent background progress. Cache-clear failures are surfaced continue to show subsequent background progress. Cache-clear failures are surfaced
instead of silently reusing cached data, and stale product responses cannot restore instead of silently reusing cached data, and stale product responses cannot restore
an invalidated cache. an invalidated cache.
On the first Opportunities modal opening in a page session, `requestCurrentUser` Opportunity retrieval first calls `requestOpportunitiesSavedSearch` and requires
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls, exactly one valid `isDefault=true` item. Its `savedSearchNumber` becomes
Reference changes and modal reopenings reuse that result. Users with `SALES_REP` `copiedFrom: queries/<savedSearchNumber>` for every opportunity page. A missing or
do not trigger CRM Service Request or Workload Actions queries or see their badges. ambiguous default blocks the dependent query instead of using a hard-coded value.
If identity or roles cannot be validated, only these two features stay disabled;
other opportunity features continue. Reload the page to refresh identity/roles.
The identity token is retained in debug responses and hidden by default; the
explicit sensitive-values toggle reveals the original value.
Consumer token recovery also handles HTTP 401/403, in addition to HTTP 200 with After all opportunity pages are displayed, SR and product enrichment start in the
invalid JSON. It opens the existing SPA session URL in an inactive temporary tab, background. Before the first `resquestCrmServiceRequest` page,
preserves the originating window and Firefox cookie store, and retries within `requestSRSearchResources` retrieves CRMServiceRequest searches with the general
the same five-attempt limit. Debug entries retain the HTTP status and recovery Token Relay Bearer. The first item's `savedSearchNumber` supplies SR `copiedFrom`;
reason. The auxiliary tab is closed after valid JSON or exhausted retries. this is independent of Opportunity's default-search selection.
SR fetches all pages of `SVC_PENDING_DELIVERY` / `ORA_SVC_MY_SR_TEAM_SRS` records
After a completed product-loading cycle with fresh API results, `resquestCrmServiceRequest` fetches all pages of through Fusion `searchResources/latest`. Each page can update badges while the
`CRMServiceRequest` records with `SVC_PENDING_DELIVERY` and `ORA_SVC_MY_SR_TEAM_SRS`, remaining pages load. These requests appear in the Service Requests debug category.
using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest`
query endpoint, independently of the opportunity query version. Calls appear in
the Service Requests debug category with masked credentials.
After a complete successful SR query (HTTP 200 with valid responses on all pages), After a complete successful SR query (HTTP 200 with valid responses on all pages),
its result is reused for subsequent Reference/product cycles in the current page its result is reused for subsequent Reference/product cycles in the current page
session. Refresh all explicitly clears it. Failed pages are retried up to five session. Refresh all explicitly clears it. Failed pages are retried up to five
@@ -87,12 +248,12 @@ SR snapshots are stored separately by update timestamp so changing Reference doe
not overwrite a previous period's snapshot. Expired snapshots are removed on not overwrite a previous period's snapshot. Expired snapshots are removed on
subsequent SR cache writes; Refresh all invalidates every snapshot. subsequent SR cache writes; Refresh all invalidates every snapshot.
No credentials, request headers or debug envelopes are stored in this cache. No credentials, request headers or debug envelopes are stored in this cache.
Products loaded from cache restore matching activity entries without network calls. Matching activity entries can be restored without network calls. Missing, expired
Missing, expired or mismatched entries are not treated as confirmed empty results; or mismatched entries remain unknown until queried, and cache-miss activity
they wait for a real product refresh before a new activity query is allowed. lookups are allowed even when the products came from cache. A completed SR result
Empty and entirely failed product cycles do not query activities. In mixed cycles, is reused and can be associated with the current product update. Empty or entirely
only fresh products permit activity API calls on cache misses; an already-loaded failed product cycles do not start product-dependent Actions; independently started
SR result is reused and associated with the new product update. SR loading does not depend on a successful product cycle.
Activity lookups do not advance the existing Last update timestamp. Activity lookups do not advance the existing Last update timestamp.
Closing and reopening the modal preserves results and does not Closing and reopening the modal preserves results and does not
reload products or trigger these lookups. Already-running product requests may reload products or trigger these lookups. Already-running product requests may
@@ -133,19 +294,33 @@ Workload Actions are queried in the background after the product cycle completes
and only after SR has completed successfully (or was restored/reused). If SR is and only after SR has completed successfully (or was restored/reused). If SR is
unavailable, Actions are not requested and remain unknown rather than absent. unavailable, Actions are not requested and remain unknown rather than absent.
Each failed workload lookup gets up to five total attempts, stopping on success Each failed workload lookup gets up to five total attempts, stopping on success
or when its session is superseded. Cached products still do not trigger network or when its session is superseded. Valid activity cache entries are reused;
lookups. Each distinct `RevnId` uses `requestWlActions` with cache misses can trigger lookups for cached products. Each distinct `RevnId` uses `requestWlActions` with
the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR
aggregates all products: green means an action's `owner` matches the general aggregates all products: green means an action's `owner` matches the general
tokenrelay response's `principal`; red means no matching owner was found. `Act ?` tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
means ownership could not be confirmed (including a missing principal). Tooltips means ownership could not be confirmed (including a missing principal). Tooltips
retain all returned actions, regardless of owner. Credentials stay in memory only; retain all returned actions, regardless of owner. The principal scopes persisted
the principal is used to scope persisted activity entries. activity entries; this data cache excludes credentials and debug envelopes.
Queries are shared per product cycle (up to four concurrently). Reference changes Queries are shared per product cycle (up to four concurrently). Reference changes
that load products from cache do not refresh activities. reuse matching activity data or load missing data as needed.
Only the first page is requested for now; `hasNext` is indicated in the tooltip. Only the first page is requested for now; `hasNext` is indicated in the tooltip.
The debug panel groups these requests under Actions, with credentials masked. The debug panel groups these requests under Actions, with credentials masked.
## Manage Time
Eligible users can open **Manage Time** once the SR session is ready. It restores
the selected reference date, moves by seven days and preserves local week drafts
while navigating within the modal. Service Request, Activity and Task Type use
searchable dependent selections; Service and Non Service rows use their respective
task types. Duplicate SR selections are rejected with row feedback.
Daily hours must be numeric and between 0 and 24 inclusive. Save creates new
entries, updates changed saved entries and applies queued deletions. Deleting an
unsaved row is local; deleting a saved row waits for Save. Partial failures retain
unfinished work without repeating confirmed writes. Loading uses skeletons and
errors expose retry. The feature is unavailable for SALES_REP.
## Build ## Build
Use Node.js: Use Node.js:
@@ -164,14 +339,12 @@ after updating; clear historical extension errors to check for new failures.
Do not edit files under `dist` directly. Both targets use Manifest V3, with a 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 Chromium service worker and Firefox background scripts. The build composes classic
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
context. It serializes `src/content/styles/redwood.css` followed by the scoped context. CSS is serialized at build time in the order defined by
`src/features/debug/styles.css`, `src/features/opportunities/styles.css`, `scripts/compose.cjs`, including feature styles followed by shared table and modal
`src/features/install-base/styles.css`, `src/features/service-requests/styles.css` overrides in `src/content/styles/tables.css` and `dialogs.css`. There is no
`src/features/consumption/plan-detail.css` and `src/features/ratecard/styles.css` at
build time. There is no
runtime module loader, browser-side eval, ES-module requirement or new dependency. runtime module loader, browser-side eval, ES-module requirement or new dependency.
## Tests and Maintenance ## Customer Insights
Installed Base and Service Requests include a search input above their tables. Installed Base and Service Requests include a search input above their tables.
Search filters loaded records across every displayed column, including raw and Search filters loaded records across every displayed column, including raw and
@@ -193,8 +366,12 @@ It uses the existing OCICONS-TS `uiaasHeader.value` in `G2m-Authorization` and i
logged in the Consumption debug category with the existing credential masking. logged in the Consumption debug category with the existing credential masking.
The eleven requested fields are displayed, including percentage discounts and The eleven requested fields are displayed, including percentage discounts and
USD prices with at least four decimal places. Search includes raw and formatted USD prices with at least four decimal places. Search includes raw and formatted
values; prices, quantities, percentages and dates sort by value. Only the drawer values; prices, quantities, percentages and dates sort by value. An **Order Number**
table scrolls, horizontally when its available width is below 1100px. multiselect beside Search groups distinct order numbers and supports selecting all
or several orders. Clicking one order while all are selected isolates that order.
Order selections and text search combine locally.
Shared table styles remove the old fixed minimum width, tighten cell spacing and
allow multiword headers to wrap at word boundaries so the table fits the drawer.
Closing the drawer, changing plans or closing Cloud Consumption discards late UI Closing the drawer, changing plans or closing Cloud Consumption discards late UI
updates. Concurrent requests for the same plan are shared; reopening after completion updates. Concurrent requests for the same plan are shared; reopening after completion
fetches again. Errors remain local. No new persisted cache, permissions, token fetches again. Errors remain local. No new persisted cache, permissions, token
@@ -233,7 +410,8 @@ and formats status as a badge and dates using the existing date formatter.
Debug contains a Service Requests category; credential headers are masked before Debug contains a Service Requests category; credential headers are masked before
delivery to content. This feature adds no permissions or authentication retries. delivery to content. This feature adds no permissions or authentication retries.
The Installed Base action sits beside customer consumption below Customer. It The Installed Base action sits beside customer consumption below Customer. The
customer appears in its modal header rather than a repeated Customer column. It
opens a skeleton dialog, then calls `requestAccountSummary` with ACCP-TS and uses opens a skeleton dialog, then calls `requestAccountSummary` with ACCP-TS and uses
the returned `registryId` in `requestInstallBase`. The latter uses the SAIB-TS the returned `registryId` in `requestInstallBase`. The latter uses the SAIB-TS
`uiaasHeader.label` and `uiaasHeader.value`, and loads pages of 25 until `hasMore` `uiaasHeader.label` and `uiaasHeader.value`, and loads pages of 25 until `hasMore`
@@ -243,26 +421,35 @@ headers in this flow are masked before delivery to the content script. No new
cache, permissions or session recovery logic is introduced. Amounts use two cache, permissions or session recovery logic is introduced. Amounts use two
decimal places without inferring a currency from the separate LC fields. decimal places without inferring a currency from the separate LC fields.
The opportunities table places product counts below Name and the customer The opportunities table places product counts and badges below Name and customer
consumption action below Customer, without a separate Actions column. Compact actions below Customer. Their spacing is coordinated; longer names can grow the
columns retain their values; Name and Customer share the remaining width while row without overlapping controls. Shared table and modal styles provide consistent
Owner is fixed at 132px. Both text cells reserve at least two text lines, and titles, typography, borders, padding and loading skeletons in both themes.
their controls stay anchored 10px above the row bottom, including while products
load. Longer text can grow the row without overlapping controls. Horizontal
scrolling is reserved for table containers below
1120px. The scoped table stylesheet does not change product-table column widths
or the existing light/dark colors.
Product HTTP requests appear in Debug Requests (Ctrl+D) as soon as they start. ## Debug Requests
Request records are persisted in page localStorage under a tab-specific namespace.
Closing the main modal preserves them. The reset occurs immediately before the
first `requestOpportunities` page of a new query; subsequent pages preserve that
query's history. Requests logged before that reset, including its saved-search
lookup, are removed by the same boundary.
The hidden debug panel does not prebuild request details. Opening it loads the
summaries and expanding an entry constructs its details. Storage is updated in
batches of changed records; quota or access failures retain the session's records
in memory with explanatory feedback rather than interrupting API requests.
Product HTTP requests appear in Debug Requests (Ctrl+Shift+D) as soon as they start.
Each entry identifies the opportunity and page offset, stays `running` while Each entry identifies the opportunity and page offset, stays `running` while
waiting for headers or the response body, and updates with HTTP status, response, waiting for headers or the response body, and updates with HTTP status, response,
duration or error. Cache hits do not create HTTP entries. Product diagnostics duration or error. Cache hits do not create HTTP entries. Product diagnostics
mask authorization and sensitive response headers before messaging the source mask authorization and sensitive response headers before messaging the source
tab/frame; this change adds no timeout, retry or authentication behavior. tab/frame; this change adds no timeout, retry or authentication behavior.
Debug Requests opens as a full-page view inside the extension using Ctrl+D, without Debug Requests opens as a full-page view inside the extension using Ctrl+Shift+D, without
opening a browser tab or entering native browser fullscreen. Close, Escape or Ctrl+D opening a browser tab or entering native browser fullscreen. Close, Escape or Ctrl+Shift+D
returns to the underlying view. Header/category navigation remains above the returns to the underlying view. Debug stays above other extension modals.
Header/category navigation remains above the
scrollable timeline; expanded details use two columns on wide screens and one on scrollable timeline; expanded details use two columns on wide screens and one on
narrow screens. Theme-specific colors distinguish success, failure, running, narrow screens. Theme-specific colors distinguish success, failure, running,
HTTP methods/statuses, request/response sections and JSON tokens. Highlighting HTTP methods/statuses, request/response sections and JSON tokens. Highlighting
@@ -273,15 +460,20 @@ are captured in full, preserving indentation, highlighting and sensitive-field
masking. Original sensitive values are retained for explicit reveal, including masking. Original sensitive values are retained for explicit reveal, including
identity, tokenrelay and catalog tokens. Already incomplete identity, tokenrelay and catalog tokens. Already incomplete
or invalid JSON remains plain text. The visibility toggle retains its existing or invalid JSON remains plain text. The visibility toggle retains its existing
behavior. Focus, expansion and timeline scroll position survive log updates. behavior. This is display masking, not encryption or guaranteed redaction at rest:
captured records can retain original sensitive values in localStorage. Focus,
expansion and timeline scroll position survive log updates.
Debug tabs group Authentication, Opportunities, Products, AI, Consumption, Installed Base and Service Requests Debug tabs group Authentication, Opportunities, Contacts, Products, Consumption,
Installed Base, Service Requests and Actions
requests; unknown request types appear under Other. Only categories with entries requests; unknown request types appear under Other. Only categories with entries
are shown. All preserves the complete timeline. Every tab sorts by request start are shown. All preserves the complete timeline. Every tab sorts by request start
time, retaining original request IDs and showing start times in collapsed rows. time, retaining original request IDs and showing start times in collapsed rows.
Counts and running states update without changing the selected category or Counts and running states update without changing the selected category or
collapsing expanded details. Use arrow keys, Home and End to navigate the tabs. collapsing expanded details. Use arrow keys, Home and End to navigate the tabs.
## Tests and Maintenance
```bash ```bash
npm test npm test
npm run check npm run check
@@ -291,83 +483,28 @@ Tests use built-in Node.js modules and synthetic credentials, without real Fusio
requests. The characterization suite verifies manifests, source/output equality, requests. The characterization suite verifies manifests, source/output equality,
fiscal dates, payloads, preferences, pagination, cache/progress and both browser API fiscal dates, payloads, preferences, pagination, cache/progress and both browser API
styles. Architecture tests enforce source inclusion, explicit factory dependencies, styles. Architecture tests enforce source inclusion, explicit factory dependencies,
isolated state and unchanged CSS. These complement consumption, session recovery isolated state and unchanged CSS. These complement consumption, session recovery,
and AI suites. `check` also fails if generated packages are stale. organization-chart and Manage Time suites. `check` also fails if generated packages are stale.
There is no separate lint configuration or runtime dependency installation. There is no separate lint configuration or runtime dependency installation.
See [architecture and contracts](docs/architecture.md) and the See [architecture and contracts](docs/architecture.md) and the
[incremental refactoring record](docs/refactoring.md) for execution boundaries, [incremental refactoring record](docs/refactoring.md) for execution boundaries,
authentication, storage, known risks and verification limits. authentication, storage, known risks and verification limits.
Mock-based tests do not replace testing the installed extension in each browser. Mock-based tests do not replace testing the installed extension in each browser.
Calendar week positioning, responsive tables, modal dragging and print pagination
still need visual checks in installed browsers; synthetic tests alone do not
certify those layouts or live Oracle authentication.
Without npm, run `node scripts/build.mjs`, `node scripts/test.cjs` and 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. `node scripts/build.mjs --check`. The documentation-baseline checks use Node 22.15.0;
the build has no runtime dependency installation step.
For a local UI smoke test, run `node scripts/preview.mjs` and open the loopback URL 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, printed by the command. The fixture uses synthetic opportunities, tokens, products,
AI text and consumption; unexpected requests fail closed. It does not authenticate and consumption data; unexpected requests fail closed. It does not authenticate
against Fusion or read real extension storage. `/consumption-trend.html` provides against Fusion or read real extension storage. `/consumption-trend.html` provides
a focused chart fixture. Stop the server with Ctrl+C when finished. 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.
- Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators.
- Opportunity type view preferences persisted in the browser.
- Background product retrieval with configurable caching, progress tracking, and expandable product details.
- Cloud consumption account-plan lookup from each opportunity, displayed in a tabbed detail modal.
- Light and dark themes based on Oracle Redwood.
- Separate outputs for Chromium/Edge and Firefox.
## Release 1.0.1
- Retries the consumer token service when it returns HTTP 200 with an HTML fallback.
- Opens the SPA session page in an inactive temporary tab and closes it as soon as valid JSON is received.
- Limits the recovery flow to five attempts and records the session bootstrap details in debug mode.
## Release 1.0.2
- Modularized the extension with regression tests and documented execution boundaries.
- Added AI opportunity summaries with a dedicated, reusable Sales Common access token.
- Added Installed Base and customer Service Requests tables with search and dark-mode support.
- Expanded Cloud Consumption with plan projections, interactive details and a searchable, sortable Rate Card drawer.
- Added Ramp Months, CRM Service Request and Workload Actions badges; SR details include direct links and copy controls.
- Added a one-time current-user lookup before the initial request flow. Users with `SALES_REP` do not query or display CRM SR/Actions.
- Improved debug request grouping, full-page viewing, response formatting and theme contrast.
- Improved compact layouts, customer actions, session recovery and completion-badge fade-out behavior.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses automated, browser-independent tests; installed-browser smoke tests were not performed for this release.
## Release 1.0.3 - 2026-10-13
- Added monthly and weekly calendar views with stage colors, fiscal week labels, daily counts and overflow details.
- Added searchable Action forms, required-field validation, saving and local badge updates.
- Added Service Request creation with dynamic lookups, payload/header confirmation and session recovery.
- Added Internal Service Request details and creation. Save reads parent data from `requestSRInfo`, resolves the current resource user, and links the new record using the parent `SrId`.
- Internal SR creation uses Session authentication, JSON, Origin and the observed App Builder client ID. Customer deadlines remain date-only; internal deadlines use midnight UTC ISO timestamps.
- Improved activity filters, cache reuse, Refresh all, progress indicators and compact layouts.
- Debug retains complete response bodies and reveals original sensitive values through the visibility toggle; values are hidden by default.
- Regenerated Chromium/Edge and Firefox distributions. Automated tests use synthetic data; real Oracle creation and installed-browser smoke tests are not part of release validation.
## Release 1.0.4 - 2026-10-19
- Added Manage Time with weekly navigation, a persisted reference date, daily hour totals and loading skeletons.
- Added searchable Service Request, Activity and Task Type fields, expanded SR tooltips and separate Service/Non Service task types.
- Existing time entries populate weekly hours and Notes. Local drafts are preserved while navigating between weeks.
- Save creates entries with POST, updates changed entries with PUT and applies queued deletions with DELETE. Writes use Session, Origin and the observed App Builder client ID.
- Added required-field validation, the 0-24 hour range, duplicate SR selection prevention, temporary row highlighting and success/error toasts.
- Reused Cache duration across form queries and invalidated cached responses after successful writes. Resource user data is reused after SR session initialization.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## Release 1.0.5 - 2026-10-20
- Corrected Reference date ranges for Oracle fiscal quarters and years, updated the opportunity query structure and removed the Renewals reference.
- Added paginated workload retrieval by unique customer account and joined workloads to products by RevnId.
- Added consumption projection comparisons using adjustedACR, RampMonths_c and the reference workbook formula, including first-month proration.
- Product and opportunity alerts identify monthly forecast differences. Single-product alerts open the comparison directly; multiple-product alerts expand the product list.
- Added Update estimates with explicit confirmation before PATCH, dynamic calculated amounts and current-user attribution, duplicate-submit protection and success/error feedback.
- Successful forecast updates refresh local comparisons and invalidate workload caches. Workloads reuse Cache duration across modal openings and token renewals.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## Loading in Development Mode ## Loading in Development Mode
### Chrome or Microsoft Edge ### Chrome or Microsoft Edge
@@ -389,3 +526,25 @@ a focused chart fixture. Stop the server with Ctrl+C when finished.
- `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome` - `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome`
When it finds the `#yourapps_groupNode_sales` group, the extension adds the tile before the `.flat-grid-cell.flat-grid-cell-addicon` item. Clicking the tile opens the opportunities interface. When it finds the `#yourapps_groupNode_sales` group, the extension adds the tile before the `.flat-grid-cell.flat-grid-cell-addicon` item. Clicking the tile opens the opportunities interface.
Opening the saved shortcut ending in `#opportunities-extension` also opens the
modal without clicking the Sales group. Its intent is captured before Fusion can
rewrite the URL, then consumed once when the extension is ready.
## Favorites, Modal Interaction and Printing
With the main modal open, **Ctrl+Shift+F** opens a searchable browser bookmark
folder picker. Choose the destination and Save; the extension uses the bookmarks
API rather than the browser's native Add Favorite dialog. If the shortcut is not
bookmarked, a prominent hint appears for 20 seconds and disappears immediately
after saving. **Last update** remains the first header badge.
Non-full-page modals can be dragged from any noninteractive area of their title
bar. Up to 70% of their width and height can move beyond the viewport; title-bar
buttons keep their normal actions. Full-page views remain fixed.
The print icon beside **Booking** and **Workload** opens a report containing the
current filters, counts, stage amounts and opportunity list. Unrestricted Customer
and Owner selections are labeled **All customers** and **All owners**. The report
requests A4 landscape with page margins and supports the browser's Print / Save PDF
dialog. Browser print settings can override the requested orientation and paper.

107
RELEASE_NOTES.md Normal file
View File

@@ -0,0 +1,107 @@
# Release Notes
## Release 1.1.0 - 2026-10-30
- Display opportunities first, then load Service Requests and update their badges in the background.
- Clicking an item when all filter choices are selected now selects that item alone, including Booking and Workload.
- Added a bookmark shortcut that opens the extension automatically from Fusion.
- Ctrl+Shift+F opens a searchable bookmarks folder picker; Ctrl+Shift+D toggles Debug Requests.
- Show a prominent favorites shortcut hint for 20 seconds when the shortcut is not bookmarked; keep Last update first in the header.
- Smaller dialogs can be dragged by their title bars, with up to 70% overflow beyond viewport edges.
- Updated Chromium/Edge and Firefox builds. Automated validation uses synthetic data; live Oracle and installed-browser testing are not included.
## Release 1.0.9 - 2026-10-28
- Added a landscape print/PDF view with filters, totals, and opportunities.
- Resolve the CRM Service Request saved search dynamically before querying records.
- Restore hidden-iframe Internal SR session recovery with bounded retries and shared pending requests.
- Align opportunity badges and customer actions consistently.
- Move release history from README to this dedicated file.
## Release 1.0.8 - 2026-10-27
- Added customer Organization Charts with contact search, contact creation, person details, notes, hierarchy editing and locally persisted chart data.
- Added paginated, sortable opportunities by contact using the contact PartyNumber.
- Unified modal and table layouts, loading skeletons and typography; added Rate Card order-number filtering and simplified Overview customer names using PartyName.
- Added Reference-bounded scrolling calendars with all daily opportunity cards and positioning around the current week.
- Added Booking/Workload filters and improved ramp/projection access, alerts and unavailable-workload indicators.
- Removed AI-generated opportunity summaries and their dedicated requests.
- Persisted debug requests in localStorage with lazy rendering; history resets only when a new opportunity query starts.
- Reuse successful current-user identity during the browser session; fixed debug timer invocation and added Token Relay timeout/retry support.
- Prepare SPA and SR sessions concurrently, inspect SPA before visual assets finish loading, and provide SR session retry with a 70-second timeout.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests are not included.
## Release 1.0.7 - 2026-10-26
- Remember table sorting preferences and restore the selected column and direction when reopening supported tables.
- Retrieve the user's Opportunity saved searches before loading opportunities and use the default savedSearchNumber for copiedFrom across all result pages.
- Show requestOpportunitiesSavedSearch in the Opportunities debug category and stop dependent queries when the default search is missing or invalid.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests were not performed for this release.
## Release 1.0.6 - 2026-10-21
- Validate the current-user JSON structure before starting dependent requests, sharing concurrent authentication work and retrying failed identity acquisition.
- Recover SPA sessions through one inactive auxiliary tab at `https://spa.oracle.com/`, preserving the originating window and Firefox cookie store.
- Inspect the SPA document before retrying identity: reject error pages, SAML/login forms, OAuth callbacks and incomplete documents. HTTP 200, cookies and elapsed time alone no longer release identity probes.
- Read identity in the verified SPA tab, at most once per document; restart rejected authentication at the home URL rather than replaying OAuth callbacks. Readiness timeouts do not trigger fallback API probes.
- Correct native timer invocation and retain protected debug diagnostics and auxiliary-tab cleanup.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle authentication and installed-browser smoke tests were not performed for this release.
## Release 1.0.5 - 2026-10-20
- Corrected Reference date ranges for Oracle fiscal quarters and years, updated the opportunity query structure and removed the Renewals reference.
- Added paginated workload retrieval by unique customer account and joined workloads to products by RevnId.
- Added consumption projection comparisons using adjustedACR, RampMonths_c and the reference workbook formula, including first-month proration.
- Product and opportunity alerts identify monthly forecast differences. Single-product alerts open the comparison directly; multiple-product alerts expand the product list.
- Added Update estimates with explicit confirmation before PATCH, dynamic calculated amounts and current-user attribution, duplicate-submit protection and success/error feedback.
- Successful forecast updates refresh local comparisons and invalidate workload caches. Workloads reuse Cache duration across modal openings and token renewals.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## Release 1.0.4 - 2026-10-19
- Added Manage Time with weekly navigation, a persisted reference date, daily hour totals and loading skeletons.
- Added searchable Service Request, Activity and Task Type fields, expanded SR tooltips and separate Service/Non Service task types.
- Existing time entries populate weekly hours and Notes. Local drafts are preserved while navigating between weeks.
- Save creates entries with POST, updates changed entries with PUT and applies queued deletions with DELETE. Writes use Session, Origin and the observed App Builder client ID.
- Added required-field validation, the 0-24 hour range, duplicate SR selection prevention, temporary row highlighting and success/error toasts.
- Reused Cache duration across form queries and invalidated cached responses after successful writes. Resource user data is reused after SR session initialization.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## Release 1.0.3 - 2026-10-13
- Added monthly and weekly calendar views with stage colors, fiscal week labels, daily counts and overflow details.
- Added searchable Action forms, required-field validation, saving and local badge updates.
- Added Service Request creation with dynamic lookups, payload/header confirmation and session recovery.
- Added Internal Service Request details and creation. Save reads parent data from `requestSRInfo`, resolves the current resource user, and links the new record using the parent `SrId`.
- Internal SR creation uses Session authentication, JSON, Origin and the observed App Builder client ID. Customer deadlines remain date-only; internal deadlines use midnight UTC ISO timestamps.
- Improved activity filters, cache reuse, Refresh all, progress indicators and compact layouts.
- Debug retains complete response bodies and reveals original sensitive values through the visibility toggle; values are hidden by default.
- Regenerated Chromium/Edge and Firefox distributions. Automated tests use synthetic data; real Oracle creation and installed-browser smoke tests are not part of release validation.
## Release 1.0.2
- Modularized the extension with regression tests and documented execution boundaries.
- Added AI opportunity summaries with a dedicated, reusable Sales Common access token.
- Added Installed Base and customer Service Requests tables with search and dark-mode support.
- Expanded Cloud Consumption with plan projections, interactive details and a searchable, sortable Rate Card drawer.
- Added Ramp Months, CRM Service Request and Workload Actions badges; SR details include direct links and copy controls.
- Added a one-time current-user lookup before the initial request flow. Users with `SALES_REP` do not query or display CRM SR/Actions.
- Improved debug request grouping, full-page viewing, response formatting and theme contrast.
- Improved compact layouts, customer actions, session recovery and completion-badge fade-out behavior.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses automated, browser-independent tests; installed-browser smoke tests were not performed for this release.
## Release 1.0.1
- Retries the consumer token service when it returns HTTP 200 with an HTML fallback.
- Opens the SPA session page in an inactive temporary tab and closes it as soon as valid JSON is received.
- Limits the recovery flow to five attempts and records the session bootstrap details in debug mode.
## Release 1.0.0
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
- Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators.
- Opportunity type view preferences persisted in the browser.
- Background product retrieval with configurable caching, progress tracking, and expandable product details.
- Cloud consumption account-plan lookup from each opportunity, displayed in a tabbed detail modal.
- Light and dark themes based on Oracle Redwood.
- Separate outputs for Chromium/Edge and Firefox.

View File

@@ -1,5 +1,140 @@
(function () { (function () {
"use strict"; "use strict";
// Source: src/features/org-chart/model.js
function createOrgChartModel() {
function normalize(nodes) {
if (!Array.isArray(nodes) || nodes.length > 500) throw new Error("An organization chart supports up to 500 people.");
const ids = new Set();
const result = nodes.map(node => {
if (!node || typeof node.id !== "string" || !/^[\w-]{1,80}$/.test(node.id) || ids.has(node.id)) throw new Error("Invalid or duplicate person ID.");
ids.add(node.id);
const item = { id: node.id, parentId: node.parentId == null ? null : node.parentId, collapsed: node.collapsed === true };
const jobTitle = node.jobTitle ?? node.role ?? "";
if (typeof jobTitle !== "string" || jobTitle.length > 160) throw new Error("Invalid Job Title.");
item.jobTitle = jobTitle.trim();
if (node.PartyNumber != null && node.PartyNumber !== "") {
if (!/^[A-Za-z0-9]{1,100}$/.test(String(node.PartyNumber))) throw new Error("Invalid contact PartyNumber.");
item.PartyNumber = String(node.PartyNumber);
}
if (node.contactPartyId != null) {
if (typeof node.contactPartyId !== "string" || !/^[\w-]{1,100}$/.test(node.contactPartyId)) throw new Error("Invalid contact ID.");
item.contactPartyId = node.contactPartyId;
}
for (const [key, max] of [["name", 160], ["notes", 4000]]) {
if (typeof node[key] !== "string" || node[key].length > max || (key === "name" && !node[key].trim())) throw new Error(`Enter a valid ${key} (up to ${max} characters).`);
item[key] = key === "notes" ? node[key] : node[key].trim();
}
return item;
});
const byId = new Map(result.map(node => [node.id, node]));
for (const node of result) {
const seen = new Set([node.id]);
let parent = node.parentId;
while (parent !== null) {
if (!byId.has(parent) || seen.has(parent)) throw new Error("The reporting structure contains a missing manager or a cycle.");
seen.add(parent);
if (seen.size > 60) throw new Error("The reporting structure supports up to 60 levels.");
parent = byId.get(parent).parentId;
}
}
return result;
}
function descendants(nodes, id) {
const found = new Set([id]);
let changed = true;
while (changed) {
changed = false;
for (const node of nodes) if (found.has(node.parentId) && !found.has(node.id)) { found.add(node.id); changed = true; }
}
return found;
}
function upsert(nodes, person) {
return normalize(nodes.some(node => node.id === person.id) ? nodes.map(node => node.id === person.id ? person : node) : [...nodes, person]);
}
function remove(nodes, id) {
const person = nodes.find(node => node.id === id);
if (!person) return nodes;
return normalize(nodes.filter(node => node.id !== id).map(node => node.parentId === id ? { ...node, parentId: person.parentId } : node));
}
function hierarchy(nodes) {
const root = { id: "", children: [] }, byId = new Map([[null, root]]);
nodes.forEach(node => byId.set(node.id, { ...node, children: [] }));
nodes.forEach(node => { if (!byId.get(node.parentId).collapsed) byId.get(node.parentId).children.push(byId.get(node.id)); });
return root;
}
return { normalize, descendants, upsert, remove, hierarchy };
}
// Source: src/features/org-chart/storage.js
function createOrgChartStorage({ environment, model }) {
let opening = null;
function database() {
if (opening) return opening;
opening = new Promise((resolve, reject) => {
if (!environment.indexedDB) { reject(new Error("IndexedDB is unavailable. The organization chart cannot be saved locally.")); return; }
const request = environment.indexedDB.open("opportunities-extension-organizations", 3);
let blocked = false;
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains("charts")) request.result.createObjectStore("charts", { keyPath: "key" });
else {
const cursor = request.transaction.objectStore("charts").openCursor();
cursor.onsuccess = () => {
const current = cursor.result;
if (!current) return;
const record = current.value;
if (Array.isArray(record.nodes)) {
record.nodes = record.nodes.map(node => {
const clean = { ...node, jobTitle: node.jobTitle ?? node.role ?? "" }; delete clean.role; delete clean.emailAddress; return clean;
});
current.update(record);
}
current.continue();
};
}
};
request.onerror = () => reject(new Error("Unable to open local organization charts."));
request.onblocked = () => { blocked = true; reject(new Error("Local storage is blocked by another window. Close it and retry.")); };
request.onsuccess = () => {
const db = request.result;
if (blocked) { db.close(); return; }
db.onversionchange = () => { db.close(); opening = null; };
resolve(db);
};
}).catch(error => { opening = null; throw error; });
return opening;
}
function identity({ principal, customerId }) {
if (typeof principal !== "string" || !principal.trim() || principal.length > 254 || typeof customerId !== "string" || !/^[\w-]{1,100}$/.test(customerId)) throw new Error("A current user and customer ID are required.");
return JSON.stringify([principal.trim().toLowerCase(), customerId]);
}
async function transact(message, write) {
const key = identity(message);
const nodes = write ? model.normalize(message.nodes) : null;
if (write && (!Number.isSafeInteger(message.revision) || message.revision < 0)) throw new Error("Invalid organization chart revision.");
const db = await database();
return new Promise((resolve, reject) => {
const transaction = db.transaction("charts", write ? "readwrite" : "readonly");
const store = transaction.objectStore("charts");
const request = store.get(key);
let result, failure;
request.onsuccess = () => {
try {
const existing = request.result;
if (existing && existing.schemaVersion !== 1) throw new Error("Unsupported organization chart version.");
if (write) {
if ((existing?.revision || 0) !== message.revision) throw new Error("This chart changed in another window. Reopen it before saving your changes.");
result = { key, schemaVersion: 1, revision: message.revision + 1, nodes, updatedAt: new Date().toISOString() };
store.put(result);
} else result = existing ? { ...existing, nodes: model.normalize(existing.nodes) } : { schemaVersion: 1, revision: 0, nodes: [], updatedAt: null };
} catch (error) { failure = error; transaction.abort(); }
};
transaction.oncomplete = () => resolve({ ok: true, chart: result });
transaction.onabort = transaction.onerror = () => reject(failure || new Error("Unable to save or read the organization chart. Check available browser storage and retry."));
});
}
return { load: message => transact(message, false), save: message => transact(message, true) };
}
// Source: src/shared/time-dates.js // Source: src/shared/time-dates.js
function createTimeDates() { function createTimeDates() {
const months = "JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split(" "); const months = "JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split(" ");
@@ -157,6 +292,11 @@ function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
/** Stable wire names. All payloads remain plain, serializable objects. */ /** Stable wire names. All payloads remain plain, serializable objects. */
function createMessageTypes() { function createMessageTypes() {
return Object.freeze({ return Object.freeze({
addShortcutBookmark: "opportunitiesExtension.addShortcutBookmark",
getBookmarkFolders: "opportunitiesExtension.getBookmarkFolders",
getShortcutBookmarkStatus: "opportunitiesExtension.getShortcutBookmarkStatus",
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
requestWeekDetails: "opportunitiesExtension.requestWeekDetails", requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
requestTimeEntries: "opportunitiesExtension.requestTimeEntries", requestTimeEntries: "opportunitiesExtension.requestTimeEntries",
requestTimeEntriesSave: "opportunitiesExtension.requestTimeEntriesSave", requestTimeEntriesSave: "opportunitiesExtension.requestTimeEntriesSave",
@@ -222,7 +362,7 @@ function createBackgroundContracts() {
const OPPTY_PRODUCTS_MAX_PAGES = 250; const OPPTY_PRODUCTS_MAX_PAGES = 250;
const OPPTY_PRODUCTS_CONCURRENCY = 4; const OPPTY_PRODUCTS_CONCURRENCY = 4;
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService"; 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_SESSION_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=whitespace";
const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5; const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5;
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750; const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000; const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;
@@ -294,9 +434,16 @@ function createAuthConsumerSession({
fetchJson, fetchJson,
supportsFirefoxCookieStoreTabs, supportsFirefoxCookieStoreTabs,
tabsCreate, tabsCreate,
tabsUpdate,
tabsRemove, tabsRemove,
responseHeaders,
cookieChanges,
inspectPage,
setTimeout,
clearTimeout,
wait wait
}) { }) {
const sessionTabs = new Map();
async function requestConsumerTokenService(sender) { async function requestConsumerTokenService(sender) {
const debugRequests = []; const debugRequests = [];
@@ -436,7 +583,8 @@ function createAuthConsumerSession({
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs); debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
} }
await wait(retryDelayMs); if (sessionTab) await waitForConsumerTokenSession(sessionTab, debugEntry, retryDelayMs);
else await wait(retryDelayMs);
} }
} }
@@ -446,7 +594,7 @@ function createAuthConsumerSession({
const debugEntry = debugRequests[debugRequests.length - 1]; const debugEntry = debugRequests[debugRequests.length - 1];
try { try {
await tabsRemove(sessionTab.id); await closeConsumerTokenSessionTab(sessionTab.id);
if (debugEntry) { if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = receivedValidJson debugEntry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json" ? "closed-after-valid-json"
@@ -498,7 +646,7 @@ function createAuthConsumerSession({
} }
} }
function openConsumerTokenSessionTab(sender) { async function openConsumerTokenSessionTab(sender) {
const details = { const details = {
url: CUSTOMER_TOKEN_SESSION_URL, url: CUSTOMER_TOKEN_SESSION_URL,
active: false active: false
@@ -512,10 +660,186 @@ function createAuthConsumerSession({
details.cookieStoreId = sender.tab.cookieStoreId; details.cookieStoreId = sender.tab.cookieStoreId;
} }
return tabsCreate(details); const tab = await tabsCreate(details);
if (Number.isInteger(tab?.id)) {
const state = { failedStatus: 0, reloads: 0, lastReloadAt: -Infinity, readyVersion: 0, consumedVersion: 0, closed: false };
const signal = reason => {
state.readyVersion++;
state.readyReason = reason;
state.wake?.();
};
state.listener = details => {
if (details.tabId !== tab.id
|| !/^https:\/\/spa\.oracle\.com\//.test(details.url)) return;
if (details.type === "main_frame") {
if (details.statusCode === 401 || details.statusCode === 403) {
state.failedStatus = details.statusCode;
state.consumedVersion = state.readyVersion;
state.wake?.();
} else if (details.statusCode >= 200 && details.statusCode < 300) {
state.failedStatus = 0;
if (!details.url.startsWith("https://spa.oracle.com/cloudgate/")) signal("spa-document-response");
}
} else if (details.type === "xmlhttprequest" && details.statusCode === 200
&& details.url.split("?")[0] === "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current"
&& details.responseHeaders?.some(header => header.name.toLowerCase() === "content-type" && /application\/json/i.test(header.value))) {
state.failedStatus = 0;
signal("spa-identity-json-response");
}
};
state.cookieListener = change => {
const cookie = change.cookie;
if (change.removed || !cookie?.value || !cookie.secure || !cookie.httpOnly
|| cookie.domain?.replace(/^\./, "") !== "spa.oracle.com" || !/session/i.test(cookie.name)
|| (cookie.expirationDate != null && cookie.expirationDate <= Date.now() / 1000)
|| (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId)) return;
signal("spa-session-cookie-changed");
};
try {
responseHeaders?.addListener(state.listener, { urls: ["https://spa.oracle.com/*"], types: ["main_frame", "xmlhttprequest"] }, ["responseHeaders"]);
cookieChanges?.addListener(state.cookieListener);
sessionTabs.set(tab.id, state);
} catch (error) {
responseHeaders?.removeListener?.(state.listener);
cookieChanges?.removeListener(state.cookieListener);
await tabsRemove(tab.id).catch(() => {});
throw error;
}
}
return tab;
} }
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab }; async function recoverConsumerTokenSessionTab(tab, debugEntry, reason = "") {
const state = sessionTabs.get(tab.id);
if (!state || (!state.failedStatus && !reason) || Date.now() - state.lastReloadAt < 2000) return false;
const status = state.failedStatus;
state.failedStatus = 0;
state.consumedVersion = state.readyVersion;
state.lastReloadAt = Date.now();
state.reloads++;
if (debugEntry) {
debugEntry.metadata.sessionBootstrapReloadCount = String(state.reloads);
debugEntry.metadata.sessionBootstrapReloadReason = reason || `SPA navigation returned HTTP ${status}`;
}
try {
// Restart at the entry point; OAuth callback URLs may contain single-use state.
await tabsUpdate(tab.id, { url: CUSTOMER_TOKEN_SESSION_URL, active: false });
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = "restarted-at-spa-home";
return true;
} catch (error) {
state.failedStatus = status;
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = `restart-failed: ${error.message || "unknown error"}`;
return false;
}
}
async function waitForSpaPage(tab, debugEntry) {
const startedAt = Date.now();
const deadline = startedAt + 120000;
let restartedDocument = null;
let restarts = 0;
if (debugEntry) {
delete debugEntry.metadata.nextRetryDelayMs;
debugEntry.metadata.sessionWaitTimeoutMs = "120000";
debugEntry.metadata.sessionWaitReason = "inspecting-spa-document";
}
while (Date.now() < deadline) {
const state = sessionTabs.get(tab.id);
if (state?.closed) break;
let page;
try { page = await inspectPage(tab); } catch { /* Cross-origin SSO pages have no SPA receiver. */ }
if (debugEntry) debugEntry.metadata.spaPageState = page?.status || "loading-or-login";
const documentId = typeof page?.documentId === "string" ? page.documentId : null;
const alreadyRead = documentId && documentId === tab.spaDocumentId;
if (state?.failedStatus || page?.status === "error" || alreadyRead) {
if (restarts < 3 && (state?.failedStatus || documentId !== restartedDocument)) {
const previousReloads = state?.reloads || 0;
const restarted = await recoverConsumerTokenSessionTab(tab, debugEntry,
state?.failedStatus ? "" : alreadyRead ? "Identity rejected in this SPA document" : "SPA document displays an authorization error");
restarts += (state?.reloads || 0) - previousReloads;
if (restarted) restartedDocument = documentId;
}
} else if (page?.status === "ready" && documentId) {
// Confirm the parsed application document without waiting for visual assets.
await wait(100);
let confirmed;
try { confirmed = await inspectPage(tab); } catch { /* A redirect started during validation. */ }
if (confirmed?.status === "ready" && confirmed.documentId === documentId && !state?.failedStatus) {
tab.spaDocumentId = documentId;
if (debugEntry) {
debugEntry.metadata.sessionWaitReason = "spa-document-verified";
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
}
return;
}
}
await wait(1000);
}
const error = new Error("SPA page did not finish authentication. No further identity request was sent.");
error.code = "SPA_PAGE_NOT_READY";
throw error;
}
async function waitForConsumerTokenSession(tab, debugEntry, retryDelay) {
const state = sessionTabs.get(tab.id);
if (!state || !responseHeaders?.addListener || !setTimeout) return wait(retryDelay);
const startedAt = Date.now();
const deadline = startedAt + 20000;
if (debugEntry) {
delete debugEntry.metadata.nextRetryDelayMs;
debugEntry.metadata.sessionWaitTimeoutMs = "20000";
}
let restarts = 0;
let reason = "timeout-fallback";
// Do not issue API probes while SSO redirects are still establishing the session.
while (!state.closed && Date.now() < deadline) {
if (state.failedStatus) {
if (restarts >= 3) {
await wait(Math.max(0, deadline - Date.now()));
break;
}
await recoverConsumerTokenSessionTab(tab, debugEntry);
restarts++;
if (state.failedStatus) await wait(2000);
continue;
}
if (state.readyVersion > state.consumedVersion) {
await wait(250);
if (state.failedStatus) continue;
state.consumedVersion = state.readyVersion;
reason = state.readyReason;
break;
}
let timer;
try {
await new Promise(resolve => {
state.wake = resolve;
timer = setTimeout(resolve, Math.max(0, deadline - Date.now()));
});
} finally {
clearTimeout?.(timer);
state.wake = null;
}
}
if (debugEntry) {
debugEntry.metadata.sessionWaitReason = reason;
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
}
}
async function closeConsumerTokenSessionTab(id) {
const state = sessionTabs.get(id);
sessionTabs.delete(id);
if (state) {
state.closed = true;
state.wake?.();
responseHeaders?.removeListener?.(state.listener);
cookieChanges?.removeListener(state.cookieListener);
}
await tabsRemove(id);
}
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab, recoverConsumerTokenSessionTab, waitForConsumerTokenSession, waitForSpaPage, closeConsumerTokenSessionTab };
} }
// Source: src/auth/spa-credentials.js // Source: src/auth/spa-credentials.js
@@ -1249,8 +1573,8 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
let tab; let tab;
try { try {
tab = await session.tab; tab = await session.tab;
const deadline = Date.now() + 60000; const deadline = Date.now() + 70000;
for (let attempt = 0; attempt < 240 && Date.now() < deadline; attempt += 1) { for (let attempt = 0; attempt < 280 && Date.now() < deadline; attempt += 1) {
const pendingCookies = initialize && attempt === 0 const pendingCookies = initialize && attempt === 0
? sessionCookies(sender) : waitForSessionCookies(session, sender); ? sessionCookies(sender) : waitForSessionCookies(session, sender);
const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies); const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies);
@@ -1269,7 +1593,7 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
if (error.httpStatus !== 401) throw error; if (error.httpStatus !== 401) throw error;
} }
} }
throw new Error("Service Request session could not be established after 60 seconds. Sign in to Oracle and retry."); throw new Error("Service Request session could not be established after 70 seconds. Sign in to Oracle and retry.");
} catch (error) { } catch (error) {
error.debugRequests = logs; error.debugRequests = logs;
throw error; throw error;
@@ -1558,28 +1882,148 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
} }
// Source: src/services/spa/current-user.js // Source: src/services/spa/current-user.js
function createSpaCurrentUser({ fetchJson }) { function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl, sessionStorage }) {
async function requestCurrentUser() { const pending = new Map();
const debugRequests = []; const identities = new Map();
let user = null; function requestCurrentUser(sender) {
// Windows share cookies; Firefox containers and private sessions do not.
const key = JSON.stringify([sender?.tab?.cookieStoreId ?? "", Boolean(sender?.tab?.incognito)]);
if (identities.has(key)) return Promise.resolve({ ok: true, user: identities.get(key), debugRequests: [] });
if (!pending.has(key)) {
pending.set(key, (async () => {
const storageKey = `opportunities-extension-current-user:${key}`;
try { try {
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { const stored = (await sessionStorage?.get(storageKey))?.[storageKey];
if (stored && typeof stored.id === "string" && stored.id.trim() && typeof stored.name === "string" && stored.name.trim()
&& Array.isArray(stored.roles) && stored.roles.every(role => typeof role === "string" && role.trim())) {
identities.set(key, stored);
return { ok: true, user: stored, debugRequests: [] };
}
} catch { /* Memory cache still works if session storage is unavailable. */ }
const result = await loadCurrentUser(sender);
identities.set(key, result.user);
try { await sessionStorage?.set({ [storageKey]: result.user }); } catch { /* Keep the successful in-memory identity. */ }
return result;
})().finally(() => pending.delete(key)));
}
return pending.get(key);
}
async function loadCurrentUser(sender) {
const debugRequests = [];
let sessionTab = null;
let receivedValidJson = false;
try {
// Bound each bootstrap cycle so a failed sign-in never leaves an orphan tab.
// The content-side gate can start another cycle without releasing dependent requests.
for (let attempt = 1; attempt <= 6; attempt++) {
try {
const result = sessionTab ? await fetchInTab(sessionTab, debugRequests) : await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" } method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }
}, "requestCurrentUser", debugRequests); }, "requestCurrentUser", debugRequests);
if (!result?.user || !Array.isArray(result.user.roles) || result.user.roles.some(role => typeof role !== "string")) { const validText = value => typeof value === "string" && value.trim().length > 0;
throw new Error("requestCurrentUser returned invalid user roles."); if (!result || typeof result !== "object" || Array.isArray(result)
|| !result.user || typeof result.user !== "object" || Array.isArray(result.user)
|| !validText(result.user.id) || !validText(result.user.name) || !validText(result.token)
|| !Array.isArray(result.user.roles) || result.user.roles.some(role => !validText(role))
|| ["onWhiteList", "provisioned", "administrator"].some(key => key in result.user && typeof result.user[key] !== "boolean")) {
const error = new Error("requestCurrentUser returned an invalid user/token response.");
error.code = "INVALID_CURRENT_USER_RESPONSE";
throw error;
} }
user = { id: result.user.id, name: result.user.name, roles: result.user.roles }; const entry = debugRequests[debugRequests.length - 1];
if (entry) entry.metadata.attempt = String(attempt);
receivedValidJson = true;
const user = { id: result.user.id, name: result.user.name, roles: result.user.roles };
return { ok: true, user, debugRequests }; return { ok: true, user, debugRequests };
} catch (error) { } catch (error) {
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; } const entry = debugRequests[debugRequests.length - 1];
if (entry) {
entry.status = "failed";
entry.error = error.message;
entry.metadata.attempt = String(attempt);
}
const needsSession = error.code === "INVALID_JSON_RESPONSE" || error.code === "INVALID_CURRENT_USER_RESPONSE"
|| error.httpStatus === 401 || error.httpStatus === 403 || !error.httpStatus;
if (!needsSession || attempt === 6) {
if (entry && needsSession) entry.metadata.retryLimitReached = "true";
throw error;
}
if (entry) {
entry.metadata.sessionBootstrapUrl = sessionUrl;
entry.metadata.retryReason = "Current user requires a valid SPA session";
}
if (!sessionTab) {
try {
const tab = await openSessionTab(sender);
if (!Number.isInteger(tab?.id)) throw new Error("SPA session tab was not created.");
sessionTab = tab;
if (entry) entry.metadata.sessionBootstrapState = "opened-inactive-tab";
} catch (sessionError) {
if (entry) entry.metadata.sessionBootstrapState = `tab-open-failed: ${sessionError.message || "unknown error"}`;
throw sessionError;
}
} else if (entry) {
entry.metadata.sessionBootstrapState = "waiting-for-valid-session";
}
await waitForSession(sessionTab, entry);
}
}
} catch (error) {
error.debugRequests = debugRequests; error.debugRequests = debugRequests;
throw error; throw error;
} finally {
if (sessionTab) {
const entry = debugRequests[debugRequests.length - 1];
try {
await closeSessionTab(sessionTab.id);
if (entry) entry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json" : "closed-after-retry-finished";
} catch (error) {
if (entry) entry.metadata.sessionBootstrapState = `tab-close-failed: ${error.message || "unknown error"}`;
}
}
} }
} }
return { requestCurrentUser }; return { requestCurrentUser };
} }
// Source: src/services/spa/page-client.js
function createSpaPageClient({ sendMessage, getHttp }) {
const options = { frameId: 0 };
const inspect = tab => sendMessage(tab.id, { type: "opportunitiesExtension.inspectSpaPage" }, options);
async function requestIdentity(tab, debugRequests) {
const http = getHttp();
const url = "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current";
const entry = http.createBackgroundDebugEntry("requestCurrentUser", url,
{ method: "GET", headers: { Accept: "application/json" } }, new Date());
entry.metadata.executionContext = "spa-tab";
try {
const result = await sendMessage(tab.id, { type: "opportunitiesExtension.readSpaIdentity", documentId: tab.spaDocumentId }, options);
if (!result || result.error || !Number.isInteger(result.status) || typeof result.body !== "string") {
throw new Error(result?.error || "Invalid SPA identity response.");
}
const response = { status: result.status, statusText: result.statusText || "", ok: result.status >= 200 && result.status < 300,
headers: { forEach: callback => Object.entries(result.headers || { "content-type": result.contentType || "" }).forEach(([key, value]) => callback(value, key)) } };
http.completeBackgroundDebugEntry(entry, response, http.createDebugBody(result.body), "");
entry.metadata.responseContentType = result.contentType || "(not provided)";
entry.metadata.responseLength = String(result.body.length);
if (result.status !== 200) {
const error = new Error(`requestCurrentUser failed with status ${result.status}.`);
error.httpStatus = result.status;
throw error;
}
return http.parseJsonResponse(result.body, "requestCurrentUser");
} catch (error) {
if (!entry.completedAt) http.completeBackgroundDebugEntry(entry, null, { value: "", truncated: false }, error.message);
entry.status = "failed";
entry.error = error.message;
throw error;
} finally { debugRequests.push(entry); }
}
return { inspect, requestIdentity };
}
// Source: src/services/spa/consumption.js // Source: src/services/spa/consumption.js
function createSpaConsumption({ function createSpaConsumption({
CONSUMPTION_TREND_REQUESTS, CONSUMPTION_TREND_REQUESTS,
@@ -2471,6 +2915,19 @@ function createPlatformBackgroundApi({
}); });
} }
function tabsUpdate(tabId, details) {
if (runtimeApi.tabs.update.length <= 2) {
return runtimeApi.tabs.update(tabId, details);
}
return new Promise((resolve, reject) => {
runtimeApi.tabs.update(tabId, details, tab => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) reject(new Error(lastError.message));
else resolve(tab);
});
});
}
function tabsRemove(tabId) { function tabsRemove(tabId) {
if (runtimeApi.tabs.remove.length <= 1) { if (runtimeApi.tabs.remove.length <= 1) {
return runtimeApi.tabs.remove(tabId); return runtimeApi.tabs.remove(tabId);
@@ -2628,7 +3085,7 @@ function createPlatformBackgroundApi({
); );
} }
return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues }; return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsUpdate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues };
} }
// Source: src/background/main.js // Source: src/background/main.js
@@ -2639,6 +3096,8 @@ function startBackgroundApplication(dependencies) {
// Source: src/background/messages.js // Source: src/background/messages.js
/** Register synchronously; replies retain the WebExtension sendResponse contract. */ /** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({ function registerBackgroundMessages({
loadOrganizationChart,
saveOrganizationChart,
messageTypes, messageTypes,
requestWeekDetails, requestWeekDetails,
requestTimeEntries, requestTimeEntries,
@@ -2681,10 +3140,57 @@ function registerBackgroundMessages({
requestOpptyProducts, requestOpptyProducts,
runtimeApi runtimeApi
}) { }) {
const bookmarkPending = new Map();
async function getBookmarkFolders() {
const folders = [];
function visit(nodes, ancestors = []) {
for (const node of nodes) {
if (node.url || node.unmodifiable) continue;
const path = node.parentId ? [...ancestors, node.title] : ancestors;
if (node.parentId) folders.push({ id: node.id, label: path.join(" / ") });
visit(node.children || [], path);
}
}
visit(await runtimeApi.bookmarks.getTree());
return { ok: true, folders };
}
function addShortcutBookmark(parentId) {
if (bookmarkPending.has(parentId)) return bookmarkPending.get(parentId);
const pending = (async () => {
const { folders } = await getBookmarkFolders();
if (!folders.some(folder => folder.id === parentId)) throw new Error("Select an available bookmarks folder.");
const url = "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension";
const existing = await runtimeApi.bookmarks.search({ url });
if (existing.some(bookmark => bookmark.parentId === parentId)) return { ok: true, existing: true };
await runtimeApi.bookmarks.create({ title: "Opportunities Extension", url, parentId });
return { ok: true, existing: false };
})().finally(() => { bookmarkPending.delete(parentId); });
bookmarkPending.set(parentId, pending);
return pending;
}
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) { if (!message || !message.type) {
return false; return false;
} }
if (message.type === messageTypes.addShortcutBookmark) {
addShortcutBookmark(message.parentId).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.getBookmarkFolders) {
getBookmarkFolders().then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.getShortcutBookmarkStatus) {
runtimeApi.bookmarks.search({ url: "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension" })
.then(items => sendResponse({ ok: true, bookmarked: items.length > 0 }))
.catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.requestLookupTaskType) { if (message.type === messageTypes.requestLookupTaskType) {
requestLookupTaskType().then(sendResponse).catch(error => sendResponse({ ok: false, requestLookupTaskType().then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] })); error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
@@ -2809,7 +3315,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
} }
if (message.type === messageTypes.requestCurrentUser) { if (message.type === messageTypes.requestCurrentUser) {
requestCurrentUser().then(sendResponse).catch(error => sendResponse({ requestCurrentUser(sender).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load current user.", ok: false, error: error.message || "Unable to load current user.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
})); }));
@@ -2909,17 +3415,32 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Source: src/background/application.js // Source: src/background/application.js
function createBackgroundApplication(overrides = {}) { function createBackgroundApplication(overrides = {}) {
const environment = { const environment = {
indexedDB: globalThis.indexedDB,
fetch: globalThis.fetch, fetch: globalThis.fetch,
browser: globalThis.browser, browser: globalThis.browser,
chrome: globalThis.chrome, chrome: globalThis.chrome,
setTimeout: globalThis.setTimeout, setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
...overrides ...overrides
}; };
const messageTypes = createMessageTypes(); const messageTypes = createMessageTypes();
const orgChartStorage = createOrgChartStorage({ environment, model: createOrgChartModel() });
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) }); const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
const contracts = createBackgroundContracts(); const contracts = createBackgroundContracts();
const spaPageClient = createSpaPageClient({
sendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args),
getHttp: () => httpBackgroundJson
});
const spaCurrentUser = createSpaCurrentUser({
sessionStorage: (environment.browser || environment.chrome)?.storage?.session,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
fetchInTab: (tab, logs) => spaPageClient.requestIdentity(tab, logs),
openSessionTab: sender => authConsumerSession.openConsumerTokenSessionTab(sender),
waitForSession: (tab, entry) => authConsumerSession.waitForSpaPage(tab, entry),
closeSessionTab: id => authConsumerSession.closeConsumerTokenSessionTab(id),
sessionUrl: contracts.CUSTOMER_TOKEN_SESSION_URL
});
const requestCurrentUser = sender => spaCurrentUser.requestCurrentUser(sender);
const productsCacheState = createProductsCacheState(); const productsCacheState = createProductsCacheState();
const authConsumerSession = createAuthConsumerSession({ const authConsumerSession = createAuthConsumerSession({
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS, CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
@@ -2931,7 +3452,13 @@ function createBackgroundApplication(overrides = {}) {
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args), tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
responseHeaders: (environment.browser || environment.chrome)?.webRequest?.onHeadersReceived,
cookieChanges: (environment.browser || environment.chrome)?.cookies?.onChanged,
inspectPage: tab => spaPageClient.inspect(tab),
setTimeout: environment.setTimeout,
clearTimeout: environment.clearTimeout,
wait: (...args) => platformBackgroundApi.wait(...args) wait: (...args) => platformBackgroundApi.wait(...args)
}); });
const authSpaCredentials = createAuthSpaCredentials({ const authSpaCredentials = createAuthSpaCredentials({
@@ -3066,6 +3593,8 @@ function createBackgroundApplication(overrides = {}) {
function start() { function start() {
srRequestHeaders.start(); srRequestHeaders.start();
startBackgroundApplication({ startBackgroundApplication({
loadOrganizationChart: message => orgChartStorage.load(message),
saveOrganizationChart: message => orgChartStorage.save(message),
requestWorkloads: message => spaWorkloads.requestWorkloads(message), requestWorkloads: message => spaWorkloads.requestWorkloads(message),
requestWorkloadForecastUpdate: message => spaWorkloads.requestWorkloadForecastUpdate(message), requestWorkloadForecastUpdate: message => spaWorkloads.requestWorkloadForecastUpdate(message),
invalidateWorkloadsCache: () => workloadsCache.invalidate(), invalidateWorkloadsCache: () => workloadsCache.invalidate(),

2746
dist/chromium/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -2,8 +2,9 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Opportunities Extension", "name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", "description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.5", "version": "1.1.0",
"permissions": [ "permissions": [
"bookmarks",
"cookies", "cookies",
"storage", "storage",
"webRequest", "webRequest",
@@ -15,6 +16,17 @@
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*" "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
], ],
"content_scripts": [ "content_scripts": [
{
"matches": [
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"
],
"js": [
"shortcut-intent.js"
],
"all_frames": false,
"run_at": "document_start"
},
{ {
"matches": [ "matches": [
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
@@ -25,6 +37,16 @@
], ],
"all_frames": true, "all_frames": true,
"run_at": "document_idle" "run_at": "document_idle"
},
{
"matches": [
"https://spa.oracle.com/*"
],
"js": [
"spa-page.js"
],
"all_frames": false,
"run_at": "document_end"
} }
], ],
"background": { "background": {

11
dist/chromium/shortcut-intent.js vendored Normal file
View File

@@ -0,0 +1,11 @@
// Capture the bookmark before Fusion rewrites its navigation URL.
(() => {
if (window.top !== window) return;
const capture = () => {
const url = new URL(window.location.href);
if (url.hash !== "#opportunities-extension" && url.searchParams.get("opportunitiesExtension") !== "open") return;
try { window.sessionStorage.setItem("opportunitiesExtension.shortcutIntent", String(Date.now())); } catch { /* The URL remains a fallback. */ }
};
capture();
window.addEventListener("hashchange", capture);
})();

58
dist/chromium/spa-page.js vendored Normal file
View File

@@ -0,0 +1,58 @@
(function () {
"use strict";
// Source: src/auth/spa-page.js
function createSpaPage() {
function start() {
if (location.origin !== "https://spa.oracle.com" || window.top !== window) return;
const runtime = (globalThis.browser || globalThis.chrome)?.runtime;
if (!runtime) return;
const documentId = `${performance.timeOrigin}:${location.pathname}`;
let identityRequest = null;
function inspect() {
const text = `${document.title}\n${document.body?.innerText || ""}`.trim();
let status = "loading";
if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error";
else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname)
|| /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title)
|| document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login";
else if ((document.readyState === "interactive" || document.readyState === "complete") && text
&& document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready";
return { status, documentId };
}
runtime.onMessage.addListener((message, sender, reply) => {
if (sender.id !== runtime.id) return false;
if (message?.type === "opportunitiesExtension.inspectSpaPage") {
reply(inspect());
return false;
}
if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false;
const page = inspect();
if (page.status !== "ready" || page.documentId !== message.documentId) {
reply({ error: "SPA page is not ready or has navigated." });
return false;
}
// A document is probed at most once; failed identity reads require a fresh navigation.
if (!identityRequest) identityRequest = (async () => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal
});
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers),
contentType: response.headers.get("content-type") || "", body: await response.text() };
} catch (error) {
return { error: error.message || "Unable to read SPA identity." };
} finally { clearTimeout(timer); }
})();
identityRequest.then(reply);
return true;
});
}
return { start };
}
createSpaPage().start();
})();

View File

@@ -1,5 +1,140 @@
(function () { (function () {
"use strict"; "use strict";
// Source: src/features/org-chart/model.js
function createOrgChartModel() {
function normalize(nodes) {
if (!Array.isArray(nodes) || nodes.length > 500) throw new Error("An organization chart supports up to 500 people.");
const ids = new Set();
const result = nodes.map(node => {
if (!node || typeof node.id !== "string" || !/^[\w-]{1,80}$/.test(node.id) || ids.has(node.id)) throw new Error("Invalid or duplicate person ID.");
ids.add(node.id);
const item = { id: node.id, parentId: node.parentId == null ? null : node.parentId, collapsed: node.collapsed === true };
const jobTitle = node.jobTitle ?? node.role ?? "";
if (typeof jobTitle !== "string" || jobTitle.length > 160) throw new Error("Invalid Job Title.");
item.jobTitle = jobTitle.trim();
if (node.PartyNumber != null && node.PartyNumber !== "") {
if (!/^[A-Za-z0-9]{1,100}$/.test(String(node.PartyNumber))) throw new Error("Invalid contact PartyNumber.");
item.PartyNumber = String(node.PartyNumber);
}
if (node.contactPartyId != null) {
if (typeof node.contactPartyId !== "string" || !/^[\w-]{1,100}$/.test(node.contactPartyId)) throw new Error("Invalid contact ID.");
item.contactPartyId = node.contactPartyId;
}
for (const [key, max] of [["name", 160], ["notes", 4000]]) {
if (typeof node[key] !== "string" || node[key].length > max || (key === "name" && !node[key].trim())) throw new Error(`Enter a valid ${key} (up to ${max} characters).`);
item[key] = key === "notes" ? node[key] : node[key].trim();
}
return item;
});
const byId = new Map(result.map(node => [node.id, node]));
for (const node of result) {
const seen = new Set([node.id]);
let parent = node.parentId;
while (parent !== null) {
if (!byId.has(parent) || seen.has(parent)) throw new Error("The reporting structure contains a missing manager or a cycle.");
seen.add(parent);
if (seen.size > 60) throw new Error("The reporting structure supports up to 60 levels.");
parent = byId.get(parent).parentId;
}
}
return result;
}
function descendants(nodes, id) {
const found = new Set([id]);
let changed = true;
while (changed) {
changed = false;
for (const node of nodes) if (found.has(node.parentId) && !found.has(node.id)) { found.add(node.id); changed = true; }
}
return found;
}
function upsert(nodes, person) {
return normalize(nodes.some(node => node.id === person.id) ? nodes.map(node => node.id === person.id ? person : node) : [...nodes, person]);
}
function remove(nodes, id) {
const person = nodes.find(node => node.id === id);
if (!person) return nodes;
return normalize(nodes.filter(node => node.id !== id).map(node => node.parentId === id ? { ...node, parentId: person.parentId } : node));
}
function hierarchy(nodes) {
const root = { id: "", children: [] }, byId = new Map([[null, root]]);
nodes.forEach(node => byId.set(node.id, { ...node, children: [] }));
nodes.forEach(node => { if (!byId.get(node.parentId).collapsed) byId.get(node.parentId).children.push(byId.get(node.id)); });
return root;
}
return { normalize, descendants, upsert, remove, hierarchy };
}
// Source: src/features/org-chart/storage.js
function createOrgChartStorage({ environment, model }) {
let opening = null;
function database() {
if (opening) return opening;
opening = new Promise((resolve, reject) => {
if (!environment.indexedDB) { reject(new Error("IndexedDB is unavailable. The organization chart cannot be saved locally.")); return; }
const request = environment.indexedDB.open("opportunities-extension-organizations", 3);
let blocked = false;
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains("charts")) request.result.createObjectStore("charts", { keyPath: "key" });
else {
const cursor = request.transaction.objectStore("charts").openCursor();
cursor.onsuccess = () => {
const current = cursor.result;
if (!current) return;
const record = current.value;
if (Array.isArray(record.nodes)) {
record.nodes = record.nodes.map(node => {
const clean = { ...node, jobTitle: node.jobTitle ?? node.role ?? "" }; delete clean.role; delete clean.emailAddress; return clean;
});
current.update(record);
}
current.continue();
};
}
};
request.onerror = () => reject(new Error("Unable to open local organization charts."));
request.onblocked = () => { blocked = true; reject(new Error("Local storage is blocked by another window. Close it and retry.")); };
request.onsuccess = () => {
const db = request.result;
if (blocked) { db.close(); return; }
db.onversionchange = () => { db.close(); opening = null; };
resolve(db);
};
}).catch(error => { opening = null; throw error; });
return opening;
}
function identity({ principal, customerId }) {
if (typeof principal !== "string" || !principal.trim() || principal.length > 254 || typeof customerId !== "string" || !/^[\w-]{1,100}$/.test(customerId)) throw new Error("A current user and customer ID are required.");
return JSON.stringify([principal.trim().toLowerCase(), customerId]);
}
async function transact(message, write) {
const key = identity(message);
const nodes = write ? model.normalize(message.nodes) : null;
if (write && (!Number.isSafeInteger(message.revision) || message.revision < 0)) throw new Error("Invalid organization chart revision.");
const db = await database();
return new Promise((resolve, reject) => {
const transaction = db.transaction("charts", write ? "readwrite" : "readonly");
const store = transaction.objectStore("charts");
const request = store.get(key);
let result, failure;
request.onsuccess = () => {
try {
const existing = request.result;
if (existing && existing.schemaVersion !== 1) throw new Error("Unsupported organization chart version.");
if (write) {
if ((existing?.revision || 0) !== message.revision) throw new Error("This chart changed in another window. Reopen it before saving your changes.");
result = { key, schemaVersion: 1, revision: message.revision + 1, nodes, updatedAt: new Date().toISOString() };
store.put(result);
} else result = existing ? { ...existing, nodes: model.normalize(existing.nodes) } : { schemaVersion: 1, revision: 0, nodes: [], updatedAt: null };
} catch (error) { failure = error; transaction.abort(); }
};
transaction.oncomplete = () => resolve({ ok: true, chart: result });
transaction.onabort = transaction.onerror = () => reject(failure || new Error("Unable to save or read the organization chart. Check available browser storage and retry."));
});
}
return { load: message => transact(message, false), save: message => transact(message, true) };
}
// Source: src/shared/time-dates.js // Source: src/shared/time-dates.js
function createTimeDates() { function createTimeDates() {
const months = "JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split(" "); const months = "JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split(" ");
@@ -157,6 +292,11 @@ function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
/** Stable wire names. All payloads remain plain, serializable objects. */ /** Stable wire names. All payloads remain plain, serializable objects. */
function createMessageTypes() { function createMessageTypes() {
return Object.freeze({ return Object.freeze({
addShortcutBookmark: "opportunitiesExtension.addShortcutBookmark",
getBookmarkFolders: "opportunitiesExtension.getBookmarkFolders",
getShortcutBookmarkStatus: "opportunitiesExtension.getShortcutBookmarkStatus",
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
requestWeekDetails: "opportunitiesExtension.requestWeekDetails", requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
requestTimeEntries: "opportunitiesExtension.requestTimeEntries", requestTimeEntries: "opportunitiesExtension.requestTimeEntries",
requestTimeEntriesSave: "opportunitiesExtension.requestTimeEntriesSave", requestTimeEntriesSave: "opportunitiesExtension.requestTimeEntriesSave",
@@ -222,7 +362,7 @@ function createBackgroundContracts() {
const OPPTY_PRODUCTS_MAX_PAGES = 250; const OPPTY_PRODUCTS_MAX_PAGES = 250;
const OPPTY_PRODUCTS_CONCURRENCY = 4; const OPPTY_PRODUCTS_CONCURRENCY = 4;
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService"; 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_SESSION_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=whitespace";
const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5; const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5;
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750; const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000; const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;
@@ -294,9 +434,16 @@ function createAuthConsumerSession({
fetchJson, fetchJson,
supportsFirefoxCookieStoreTabs, supportsFirefoxCookieStoreTabs,
tabsCreate, tabsCreate,
tabsUpdate,
tabsRemove, tabsRemove,
responseHeaders,
cookieChanges,
inspectPage,
setTimeout,
clearTimeout,
wait wait
}) { }) {
const sessionTabs = new Map();
async function requestConsumerTokenService(sender) { async function requestConsumerTokenService(sender) {
const debugRequests = []; const debugRequests = [];
@@ -436,7 +583,8 @@ function createAuthConsumerSession({
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs); debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
} }
await wait(retryDelayMs); if (sessionTab) await waitForConsumerTokenSession(sessionTab, debugEntry, retryDelayMs);
else await wait(retryDelayMs);
} }
} }
@@ -446,7 +594,7 @@ function createAuthConsumerSession({
const debugEntry = debugRequests[debugRequests.length - 1]; const debugEntry = debugRequests[debugRequests.length - 1];
try { try {
await tabsRemove(sessionTab.id); await closeConsumerTokenSessionTab(sessionTab.id);
if (debugEntry) { if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = receivedValidJson debugEntry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json" ? "closed-after-valid-json"
@@ -498,7 +646,7 @@ function createAuthConsumerSession({
} }
} }
function openConsumerTokenSessionTab(sender) { async function openConsumerTokenSessionTab(sender) {
const details = { const details = {
url: CUSTOMER_TOKEN_SESSION_URL, url: CUSTOMER_TOKEN_SESSION_URL,
active: false active: false
@@ -512,10 +660,186 @@ function createAuthConsumerSession({
details.cookieStoreId = sender.tab.cookieStoreId; details.cookieStoreId = sender.tab.cookieStoreId;
} }
return tabsCreate(details); const tab = await tabsCreate(details);
if (Number.isInteger(tab?.id)) {
const state = { failedStatus: 0, reloads: 0, lastReloadAt: -Infinity, readyVersion: 0, consumedVersion: 0, closed: false };
const signal = reason => {
state.readyVersion++;
state.readyReason = reason;
state.wake?.();
};
state.listener = details => {
if (details.tabId !== tab.id
|| !/^https:\/\/spa\.oracle\.com\//.test(details.url)) return;
if (details.type === "main_frame") {
if (details.statusCode === 401 || details.statusCode === 403) {
state.failedStatus = details.statusCode;
state.consumedVersion = state.readyVersion;
state.wake?.();
} else if (details.statusCode >= 200 && details.statusCode < 300) {
state.failedStatus = 0;
if (!details.url.startsWith("https://spa.oracle.com/cloudgate/")) signal("spa-document-response");
}
} else if (details.type === "xmlhttprequest" && details.statusCode === 200
&& details.url.split("?")[0] === "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current"
&& details.responseHeaders?.some(header => header.name.toLowerCase() === "content-type" && /application\/json/i.test(header.value))) {
state.failedStatus = 0;
signal("spa-identity-json-response");
}
};
state.cookieListener = change => {
const cookie = change.cookie;
if (change.removed || !cookie?.value || !cookie.secure || !cookie.httpOnly
|| cookie.domain?.replace(/^\./, "") !== "spa.oracle.com" || !/session/i.test(cookie.name)
|| (cookie.expirationDate != null && cookie.expirationDate <= Date.now() / 1000)
|| (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId)) return;
signal("spa-session-cookie-changed");
};
try {
responseHeaders?.addListener(state.listener, { urls: ["https://spa.oracle.com/*"], types: ["main_frame", "xmlhttprequest"] }, ["responseHeaders"]);
cookieChanges?.addListener(state.cookieListener);
sessionTabs.set(tab.id, state);
} catch (error) {
responseHeaders?.removeListener?.(state.listener);
cookieChanges?.removeListener(state.cookieListener);
await tabsRemove(tab.id).catch(() => {});
throw error;
}
}
return tab;
} }
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab }; async function recoverConsumerTokenSessionTab(tab, debugEntry, reason = "") {
const state = sessionTabs.get(tab.id);
if (!state || (!state.failedStatus && !reason) || Date.now() - state.lastReloadAt < 2000) return false;
const status = state.failedStatus;
state.failedStatus = 0;
state.consumedVersion = state.readyVersion;
state.lastReloadAt = Date.now();
state.reloads++;
if (debugEntry) {
debugEntry.metadata.sessionBootstrapReloadCount = String(state.reloads);
debugEntry.metadata.sessionBootstrapReloadReason = reason || `SPA navigation returned HTTP ${status}`;
}
try {
// Restart at the entry point; OAuth callback URLs may contain single-use state.
await tabsUpdate(tab.id, { url: CUSTOMER_TOKEN_SESSION_URL, active: false });
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = "restarted-at-spa-home";
return true;
} catch (error) {
state.failedStatus = status;
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = `restart-failed: ${error.message || "unknown error"}`;
return false;
}
}
async function waitForSpaPage(tab, debugEntry) {
const startedAt = Date.now();
const deadline = startedAt + 120000;
let restartedDocument = null;
let restarts = 0;
if (debugEntry) {
delete debugEntry.metadata.nextRetryDelayMs;
debugEntry.metadata.sessionWaitTimeoutMs = "120000";
debugEntry.metadata.sessionWaitReason = "inspecting-spa-document";
}
while (Date.now() < deadline) {
const state = sessionTabs.get(tab.id);
if (state?.closed) break;
let page;
try { page = await inspectPage(tab); } catch { /* Cross-origin SSO pages have no SPA receiver. */ }
if (debugEntry) debugEntry.metadata.spaPageState = page?.status || "loading-or-login";
const documentId = typeof page?.documentId === "string" ? page.documentId : null;
const alreadyRead = documentId && documentId === tab.spaDocumentId;
if (state?.failedStatus || page?.status === "error" || alreadyRead) {
if (restarts < 3 && (state?.failedStatus || documentId !== restartedDocument)) {
const previousReloads = state?.reloads || 0;
const restarted = await recoverConsumerTokenSessionTab(tab, debugEntry,
state?.failedStatus ? "" : alreadyRead ? "Identity rejected in this SPA document" : "SPA document displays an authorization error");
restarts += (state?.reloads || 0) - previousReloads;
if (restarted) restartedDocument = documentId;
}
} else if (page?.status === "ready" && documentId) {
// Confirm the parsed application document without waiting for visual assets.
await wait(100);
let confirmed;
try { confirmed = await inspectPage(tab); } catch { /* A redirect started during validation. */ }
if (confirmed?.status === "ready" && confirmed.documentId === documentId && !state?.failedStatus) {
tab.spaDocumentId = documentId;
if (debugEntry) {
debugEntry.metadata.sessionWaitReason = "spa-document-verified";
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
}
return;
}
}
await wait(1000);
}
const error = new Error("SPA page did not finish authentication. No further identity request was sent.");
error.code = "SPA_PAGE_NOT_READY";
throw error;
}
async function waitForConsumerTokenSession(tab, debugEntry, retryDelay) {
const state = sessionTabs.get(tab.id);
if (!state || !responseHeaders?.addListener || !setTimeout) return wait(retryDelay);
const startedAt = Date.now();
const deadline = startedAt + 20000;
if (debugEntry) {
delete debugEntry.metadata.nextRetryDelayMs;
debugEntry.metadata.sessionWaitTimeoutMs = "20000";
}
let restarts = 0;
let reason = "timeout-fallback";
// Do not issue API probes while SSO redirects are still establishing the session.
while (!state.closed && Date.now() < deadline) {
if (state.failedStatus) {
if (restarts >= 3) {
await wait(Math.max(0, deadline - Date.now()));
break;
}
await recoverConsumerTokenSessionTab(tab, debugEntry);
restarts++;
if (state.failedStatus) await wait(2000);
continue;
}
if (state.readyVersion > state.consumedVersion) {
await wait(250);
if (state.failedStatus) continue;
state.consumedVersion = state.readyVersion;
reason = state.readyReason;
break;
}
let timer;
try {
await new Promise(resolve => {
state.wake = resolve;
timer = setTimeout(resolve, Math.max(0, deadline - Date.now()));
});
} finally {
clearTimeout?.(timer);
state.wake = null;
}
}
if (debugEntry) {
debugEntry.metadata.sessionWaitReason = reason;
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
}
}
async function closeConsumerTokenSessionTab(id) {
const state = sessionTabs.get(id);
sessionTabs.delete(id);
if (state) {
state.closed = true;
state.wake?.();
responseHeaders?.removeListener?.(state.listener);
cookieChanges?.removeListener(state.cookieListener);
}
await tabsRemove(id);
}
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab, recoverConsumerTokenSessionTab, waitForConsumerTokenSession, waitForSpaPage, closeConsumerTokenSessionTab };
} }
// Source: src/auth/spa-credentials.js // Source: src/auth/spa-credentials.js
@@ -1249,8 +1573,8 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
let tab; let tab;
try { try {
tab = await session.tab; tab = await session.tab;
const deadline = Date.now() + 60000; const deadline = Date.now() + 70000;
for (let attempt = 0; attempt < 240 && Date.now() < deadline; attempt += 1) { for (let attempt = 0; attempt < 280 && Date.now() < deadline; attempt += 1) {
const pendingCookies = initialize && attempt === 0 const pendingCookies = initialize && attempt === 0
? sessionCookies(sender) : waitForSessionCookies(session, sender); ? sessionCookies(sender) : waitForSessionCookies(session, sender);
const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies); const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies);
@@ -1269,7 +1593,7 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
if (error.httpStatus !== 401) throw error; if (error.httpStatus !== 401) throw error;
} }
} }
throw new Error("Service Request session could not be established after 60 seconds. Sign in to Oracle and retry."); throw new Error("Service Request session could not be established after 70 seconds. Sign in to Oracle and retry.");
} catch (error) { } catch (error) {
error.debugRequests = logs; error.debugRequests = logs;
throw error; throw error;
@@ -1558,28 +1882,148 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
} }
// Source: src/services/spa/current-user.js // Source: src/services/spa/current-user.js
function createSpaCurrentUser({ fetchJson }) { function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl, sessionStorage }) {
async function requestCurrentUser() { const pending = new Map();
const debugRequests = []; const identities = new Map();
let user = null; function requestCurrentUser(sender) {
// Windows share cookies; Firefox containers and private sessions do not.
const key = JSON.stringify([sender?.tab?.cookieStoreId ?? "", Boolean(sender?.tab?.incognito)]);
if (identities.has(key)) return Promise.resolve({ ok: true, user: identities.get(key), debugRequests: [] });
if (!pending.has(key)) {
pending.set(key, (async () => {
const storageKey = `opportunities-extension-current-user:${key}`;
try { try {
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { const stored = (await sessionStorage?.get(storageKey))?.[storageKey];
if (stored && typeof stored.id === "string" && stored.id.trim() && typeof stored.name === "string" && stored.name.trim()
&& Array.isArray(stored.roles) && stored.roles.every(role => typeof role === "string" && role.trim())) {
identities.set(key, stored);
return { ok: true, user: stored, debugRequests: [] };
}
} catch { /* Memory cache still works if session storage is unavailable. */ }
const result = await loadCurrentUser(sender);
identities.set(key, result.user);
try { await sessionStorage?.set({ [storageKey]: result.user }); } catch { /* Keep the successful in-memory identity. */ }
return result;
})().finally(() => pending.delete(key)));
}
return pending.get(key);
}
async function loadCurrentUser(sender) {
const debugRequests = [];
let sessionTab = null;
let receivedValidJson = false;
try {
// Bound each bootstrap cycle so a failed sign-in never leaves an orphan tab.
// The content-side gate can start another cycle without releasing dependent requests.
for (let attempt = 1; attempt <= 6; attempt++) {
try {
const result = sessionTab ? await fetchInTab(sessionTab, debugRequests) : await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" } method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }
}, "requestCurrentUser", debugRequests); }, "requestCurrentUser", debugRequests);
if (!result?.user || !Array.isArray(result.user.roles) || result.user.roles.some(role => typeof role !== "string")) { const validText = value => typeof value === "string" && value.trim().length > 0;
throw new Error("requestCurrentUser returned invalid user roles."); if (!result || typeof result !== "object" || Array.isArray(result)
|| !result.user || typeof result.user !== "object" || Array.isArray(result.user)
|| !validText(result.user.id) || !validText(result.user.name) || !validText(result.token)
|| !Array.isArray(result.user.roles) || result.user.roles.some(role => !validText(role))
|| ["onWhiteList", "provisioned", "administrator"].some(key => key in result.user && typeof result.user[key] !== "boolean")) {
const error = new Error("requestCurrentUser returned an invalid user/token response.");
error.code = "INVALID_CURRENT_USER_RESPONSE";
throw error;
} }
user = { id: result.user.id, name: result.user.name, roles: result.user.roles }; const entry = debugRequests[debugRequests.length - 1];
if (entry) entry.metadata.attempt = String(attempt);
receivedValidJson = true;
const user = { id: result.user.id, name: result.user.name, roles: result.user.roles };
return { ok: true, user, debugRequests }; return { ok: true, user, debugRequests };
} catch (error) { } catch (error) {
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; } const entry = debugRequests[debugRequests.length - 1];
if (entry) {
entry.status = "failed";
entry.error = error.message;
entry.metadata.attempt = String(attempt);
}
const needsSession = error.code === "INVALID_JSON_RESPONSE" || error.code === "INVALID_CURRENT_USER_RESPONSE"
|| error.httpStatus === 401 || error.httpStatus === 403 || !error.httpStatus;
if (!needsSession || attempt === 6) {
if (entry && needsSession) entry.metadata.retryLimitReached = "true";
throw error;
}
if (entry) {
entry.metadata.sessionBootstrapUrl = sessionUrl;
entry.metadata.retryReason = "Current user requires a valid SPA session";
}
if (!sessionTab) {
try {
const tab = await openSessionTab(sender);
if (!Number.isInteger(tab?.id)) throw new Error("SPA session tab was not created.");
sessionTab = tab;
if (entry) entry.metadata.sessionBootstrapState = "opened-inactive-tab";
} catch (sessionError) {
if (entry) entry.metadata.sessionBootstrapState = `tab-open-failed: ${sessionError.message || "unknown error"}`;
throw sessionError;
}
} else if (entry) {
entry.metadata.sessionBootstrapState = "waiting-for-valid-session";
}
await waitForSession(sessionTab, entry);
}
}
} catch (error) {
error.debugRequests = debugRequests; error.debugRequests = debugRequests;
throw error; throw error;
} finally {
if (sessionTab) {
const entry = debugRequests[debugRequests.length - 1];
try {
await closeSessionTab(sessionTab.id);
if (entry) entry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json" : "closed-after-retry-finished";
} catch (error) {
if (entry) entry.metadata.sessionBootstrapState = `tab-close-failed: ${error.message || "unknown error"}`;
}
}
} }
} }
return { requestCurrentUser }; return { requestCurrentUser };
} }
// Source: src/services/spa/page-client.js
function createSpaPageClient({ sendMessage, getHttp }) {
const options = { frameId: 0 };
const inspect = tab => sendMessage(tab.id, { type: "opportunitiesExtension.inspectSpaPage" }, options);
async function requestIdentity(tab, debugRequests) {
const http = getHttp();
const url = "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current";
const entry = http.createBackgroundDebugEntry("requestCurrentUser", url,
{ method: "GET", headers: { Accept: "application/json" } }, new Date());
entry.metadata.executionContext = "spa-tab";
try {
const result = await sendMessage(tab.id, { type: "opportunitiesExtension.readSpaIdentity", documentId: tab.spaDocumentId }, options);
if (!result || result.error || !Number.isInteger(result.status) || typeof result.body !== "string") {
throw new Error(result?.error || "Invalid SPA identity response.");
}
const response = { status: result.status, statusText: result.statusText || "", ok: result.status >= 200 && result.status < 300,
headers: { forEach: callback => Object.entries(result.headers || { "content-type": result.contentType || "" }).forEach(([key, value]) => callback(value, key)) } };
http.completeBackgroundDebugEntry(entry, response, http.createDebugBody(result.body), "");
entry.metadata.responseContentType = result.contentType || "(not provided)";
entry.metadata.responseLength = String(result.body.length);
if (result.status !== 200) {
const error = new Error(`requestCurrentUser failed with status ${result.status}.`);
error.httpStatus = result.status;
throw error;
}
return http.parseJsonResponse(result.body, "requestCurrentUser");
} catch (error) {
if (!entry.completedAt) http.completeBackgroundDebugEntry(entry, null, { value: "", truncated: false }, error.message);
entry.status = "failed";
entry.error = error.message;
throw error;
} finally { debugRequests.push(entry); }
}
return { inspect, requestIdentity };
}
// Source: src/services/spa/consumption.js // Source: src/services/spa/consumption.js
function createSpaConsumption({ function createSpaConsumption({
CONSUMPTION_TREND_REQUESTS, CONSUMPTION_TREND_REQUESTS,
@@ -2471,6 +2915,19 @@ function createPlatformBackgroundApi({
}); });
} }
function tabsUpdate(tabId, details) {
if (runtimeApi.tabs.update.length <= 2) {
return runtimeApi.tabs.update(tabId, details);
}
return new Promise((resolve, reject) => {
runtimeApi.tabs.update(tabId, details, tab => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) reject(new Error(lastError.message));
else resolve(tab);
});
});
}
function tabsRemove(tabId) { function tabsRemove(tabId) {
if (runtimeApi.tabs.remove.length <= 1) { if (runtimeApi.tabs.remove.length <= 1) {
return runtimeApi.tabs.remove(tabId); return runtimeApi.tabs.remove(tabId);
@@ -2628,7 +3085,7 @@ function createPlatformBackgroundApi({
); );
} }
return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues }; return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsUpdate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues };
} }
// Source: src/background/main.js // Source: src/background/main.js
@@ -2639,6 +3096,8 @@ function startBackgroundApplication(dependencies) {
// Source: src/background/messages.js // Source: src/background/messages.js
/** Register synchronously; replies retain the WebExtension sendResponse contract. */ /** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({ function registerBackgroundMessages({
loadOrganizationChart,
saveOrganizationChart,
messageTypes, messageTypes,
requestWeekDetails, requestWeekDetails,
requestTimeEntries, requestTimeEntries,
@@ -2681,10 +3140,57 @@ function registerBackgroundMessages({
requestOpptyProducts, requestOpptyProducts,
runtimeApi runtimeApi
}) { }) {
const bookmarkPending = new Map();
async function getBookmarkFolders() {
const folders = [];
function visit(nodes, ancestors = []) {
for (const node of nodes) {
if (node.url || node.unmodifiable) continue;
const path = node.parentId ? [...ancestors, node.title] : ancestors;
if (node.parentId) folders.push({ id: node.id, label: path.join(" / ") });
visit(node.children || [], path);
}
}
visit(await runtimeApi.bookmarks.getTree());
return { ok: true, folders };
}
function addShortcutBookmark(parentId) {
if (bookmarkPending.has(parentId)) return bookmarkPending.get(parentId);
const pending = (async () => {
const { folders } = await getBookmarkFolders();
if (!folders.some(folder => folder.id === parentId)) throw new Error("Select an available bookmarks folder.");
const url = "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension";
const existing = await runtimeApi.bookmarks.search({ url });
if (existing.some(bookmark => bookmark.parentId === parentId)) return { ok: true, existing: true };
await runtimeApi.bookmarks.create({ title: "Opportunities Extension", url, parentId });
return { ok: true, existing: false };
})().finally(() => { bookmarkPending.delete(parentId); });
bookmarkPending.set(parentId, pending);
return pending;
}
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) { if (!message || !message.type) {
return false; return false;
} }
if (message.type === messageTypes.addShortcutBookmark) {
addShortcutBookmark(message.parentId).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.getBookmarkFolders) {
getBookmarkFolders().then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.getShortcutBookmarkStatus) {
runtimeApi.bookmarks.search({ url: "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension" })
.then(items => sendResponse({ ok: true, bookmarked: items.length > 0 }))
.catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.requestLookupTaskType) { if (message.type === messageTypes.requestLookupTaskType) {
requestLookupTaskType().then(sendResponse).catch(error => sendResponse({ ok: false, requestLookupTaskType().then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] })); error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
@@ -2809,7 +3315,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
} }
if (message.type === messageTypes.requestCurrentUser) { if (message.type === messageTypes.requestCurrentUser) {
requestCurrentUser().then(sendResponse).catch(error => sendResponse({ requestCurrentUser(sender).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load current user.", ok: false, error: error.message || "Unable to load current user.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
})); }));
@@ -2909,17 +3415,32 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Source: src/background/application.js // Source: src/background/application.js
function createBackgroundApplication(overrides = {}) { function createBackgroundApplication(overrides = {}) {
const environment = { const environment = {
indexedDB: globalThis.indexedDB,
fetch: globalThis.fetch, fetch: globalThis.fetch,
browser: globalThis.browser, browser: globalThis.browser,
chrome: globalThis.chrome, chrome: globalThis.chrome,
setTimeout: globalThis.setTimeout, setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
...overrides ...overrides
}; };
const messageTypes = createMessageTypes(); const messageTypes = createMessageTypes();
const orgChartStorage = createOrgChartStorage({ environment, model: createOrgChartModel() });
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) }); const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
const contracts = createBackgroundContracts(); const contracts = createBackgroundContracts();
const spaPageClient = createSpaPageClient({
sendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args),
getHttp: () => httpBackgroundJson
});
const spaCurrentUser = createSpaCurrentUser({
sessionStorage: (environment.browser || environment.chrome)?.storage?.session,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
fetchInTab: (tab, logs) => spaPageClient.requestIdentity(tab, logs),
openSessionTab: sender => authConsumerSession.openConsumerTokenSessionTab(sender),
waitForSession: (tab, entry) => authConsumerSession.waitForSpaPage(tab, entry),
closeSessionTab: id => authConsumerSession.closeConsumerTokenSessionTab(id),
sessionUrl: contracts.CUSTOMER_TOKEN_SESSION_URL
});
const requestCurrentUser = sender => spaCurrentUser.requestCurrentUser(sender);
const productsCacheState = createProductsCacheState(); const productsCacheState = createProductsCacheState();
const authConsumerSession = createAuthConsumerSession({ const authConsumerSession = createAuthConsumerSession({
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS, CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
@@ -2931,7 +3452,13 @@ function createBackgroundApplication(overrides = {}) {
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args), tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
responseHeaders: (environment.browser || environment.chrome)?.webRequest?.onHeadersReceived,
cookieChanges: (environment.browser || environment.chrome)?.cookies?.onChanged,
inspectPage: tab => spaPageClient.inspect(tab),
setTimeout: environment.setTimeout,
clearTimeout: environment.clearTimeout,
wait: (...args) => platformBackgroundApi.wait(...args) wait: (...args) => platformBackgroundApi.wait(...args)
}); });
const authSpaCredentials = createAuthSpaCredentials({ const authSpaCredentials = createAuthSpaCredentials({
@@ -3066,6 +3593,8 @@ function createBackgroundApplication(overrides = {}) {
function start() { function start() {
srRequestHeaders.start(); srRequestHeaders.start();
startBackgroundApplication({ startBackgroundApplication({
loadOrganizationChart: message => orgChartStorage.load(message),
saveOrganizationChart: message => orgChartStorage.save(message),
requestWorkloads: message => spaWorkloads.requestWorkloads(message), requestWorkloads: message => spaWorkloads.requestWorkloads(message),
requestWorkloadForecastUpdate: message => spaWorkloads.requestWorkloadForecastUpdate(message), requestWorkloadForecastUpdate: message => spaWorkloads.requestWorkloadForecastUpdate(message),
invalidateWorkloadsCache: () => workloadsCache.invalidate(), invalidateWorkloadsCache: () => workloadsCache.invalidate(),

2746
dist/firefox/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -2,8 +2,9 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Opportunities Extension", "name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", "description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.5", "version": "1.1.0",
"permissions": [ "permissions": [
"bookmarks",
"cookies", "cookies",
"storage", "storage",
"webRequest", "webRequest",
@@ -15,6 +16,17 @@
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*" "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
], ],
"content_scripts": [ "content_scripts": [
{
"matches": [
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"
],
"js": [
"shortcut-intent.js"
],
"all_frames": false,
"run_at": "document_start"
},
{ {
"matches": [ "matches": [
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
@@ -25,6 +37,16 @@
], ],
"all_frames": true, "all_frames": true,
"run_at": "document_idle" "run_at": "document_idle"
},
{
"matches": [
"https://spa.oracle.com/*"
],
"js": [
"spa-page.js"
],
"all_frames": false,
"run_at": "document_end"
} }
], ],
"background": { "background": {

11
dist/firefox/shortcut-intent.js vendored Normal file
View File

@@ -0,0 +1,11 @@
// Capture the bookmark before Fusion rewrites its navigation URL.
(() => {
if (window.top !== window) return;
const capture = () => {
const url = new URL(window.location.href);
if (url.hash !== "#opportunities-extension" && url.searchParams.get("opportunitiesExtension") !== "open") return;
try { window.sessionStorage.setItem("opportunitiesExtension.shortcutIntent", String(Date.now())); } catch { /* The URL remains a fallback. */ }
};
capture();
window.addEventListener("hashchange", capture);
})();

58
dist/firefox/spa-page.js vendored Normal file
View File

@@ -0,0 +1,58 @@
(function () {
"use strict";
// Source: src/auth/spa-page.js
function createSpaPage() {
function start() {
if (location.origin !== "https://spa.oracle.com" || window.top !== window) return;
const runtime = (globalThis.browser || globalThis.chrome)?.runtime;
if (!runtime) return;
const documentId = `${performance.timeOrigin}:${location.pathname}`;
let identityRequest = null;
function inspect() {
const text = `${document.title}\n${document.body?.innerText || ""}`.trim();
let status = "loading";
if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error";
else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname)
|| /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title)
|| document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login";
else if ((document.readyState === "interactive" || document.readyState === "complete") && text
&& document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready";
return { status, documentId };
}
runtime.onMessage.addListener((message, sender, reply) => {
if (sender.id !== runtime.id) return false;
if (message?.type === "opportunitiesExtension.inspectSpaPage") {
reply(inspect());
return false;
}
if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false;
const page = inspect();
if (page.status !== "ready" || page.documentId !== message.documentId) {
reply({ error: "SPA page is not ready or has navigated." });
return false;
}
// A document is probed at most once; failed identity reads require a fresh navigation.
if (!identityRequest) identityRequest = (async () => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal
});
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers),
contentType: response.headers.get("content-type") || "", body: await response.text() };
} catch (error) {
return { error: error.message || "Unable to read SPA identity." };
} finally { clearTimeout(timer); }
})();
identityRequest.then(reply);
return true;
});
}
return { start };
}
createSpaPage().start();
})();

View File

@@ -1,7 +1,14 @@
# Architecture and Compatibility Contracts # Architecture and Compatibility Contracts
This document describes the integrated modular implementation. Verification and This document describes the current checkout, including behavior added after the
preexisting risks are tracked in [refactoring.md](refactoring.md). 1.1.0 release. [refactoring.md](refactoring.md) records the earlier modularization;
historical observations there do not override the current contracts below.
The [OpenSpec capability baseline](../openspec/specs/) and its archived
[source/test evidence map](../openspec/changes/archive/2026-10-31-document-project-baseline/design.md)
provide behavioral context. The initial change was completed and archived on
2026-10-31, promoting all twelve capabilities to durable specifications. Use those
capabilities for subsequent delta changes.
## Sources, Outputs, and Runtime Boundaries ## Sources, Outputs, and Runtime Boundaries
@@ -12,12 +19,24 @@ preexisting risks are tracked in [refactoring.md](refactoring.md).
- `dist/chromium` serves Chrome and Edge; `dist/firefox` serves Firefox. Do not edit outputs. - `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 - Both manifests use V3. Chromium has a classic background service worker; Firefox
uses `background.scripts`. Neither entry point is declared as a module. uses `background.scripts`. Neither entry point is declared as a module.
- Content scripts run at `document_idle`, in all matching frames on the two - `src/content/shortcut-intent.js` runs at `document_start` in the top frame,
preserving bookmark intent before Fusion rewrites the URL. The main content
script runs at `document_idle`, in all matching frames on the two
configured Fusion welcome pages. Their code also checks the hostname/path. configured Fusion welcome pages. Their code also checks the hostname/path.
- `src/auth/spa-page.js` runs at `document_end` as a separate top-frame-only SPA content script, copied
into both distributions. It handles only extension messages for DOM readiness
inspection and a fixed same-origin identity GET; it sends no requests on its own.
`services/spa/page-client.js` connects this receiver to the background recovery
flow and the existing debug envelope. No page-world script is injected.
- There are no extension UI pages, injected page-context scripts, custom CSP, - There are no extension UI pages, injected page-context scripts, custom CSP,
declared execution world, or web-accessible resources. declared execution world, or web-accessible resources.
- Permissions remain `cookies` and `storage`, with host access limited to the - `scripts/build.mjs` defines permissions: `bookmarks`, `cookies`, `storage`,
configured Fusion host and `spa.oracle.com`. `webRequest` and `declarativeNetRequestWithHostAccess`. Host permissions cover
`eeho.fa.us2.oraclecloud.com`, `spa.oracle.com`, and
`comcipapic-oalprod.integration.ocp.oraclecloud.com` over HTTPS.
- Bookmark access supports folder selection and shortcut detection. SR integration
observes App Builder client IDs and installs narrow session rules for required
Origin headers; it is not a general request proxy.
Content owns the DOM and per-frame state. Background owns privileged cookies, Content owns the DOM and per-frame state. Background owns privileged cookies,
temporary tabs, extension storage and SPA requests. These contexts communicate temporary tabs, extension storage and SPA requests. These contexts communicate
@@ -35,41 +54,67 @@ different from extension storage and is not a safe place for credentials.
| Content | Plan projection details | Per-panel WeakMap, shared in-flight requests, nested tooltip DOM, temporary pointer/resize listeners cleaned up on dismiss/tab change/modal close | | Content | Plan projection details | Per-panel WeakMap, shared in-flight requests, nested tooltip DOM, temporary pointer/resize listeners cleaned up on dismiss/tab change/modal close |
| Content | Rate card | Pure column/filter/sort model, controller with per-plan in-flight sharing, animated drawer with skeleton/search/table; response guards and cleanup on close or plan switch | | Content | Rate card | Pure column/filter/sort model, controller with per-plan in-flight sharing, animated drawer with skeleton/search/table; response guards and cleanup on close or plan switch |
| Content | Service Requests UI | Customer-specific in-flight queries, skeleton/table modal, stale-response guards; no persistent cache | | Content | Service Requests UI | Customer-specific in-flight queries, skeleton/table modal, stale-response guards; no persistent cache |
| Content | AI summary | Per-opportunity in-flight state and summary; fresh generation on expansion | | Content | Organization Chart | Working hierarchy, contact autocomplete/creation, transient email and contact-opportunity views |
| Content | Authentication/debug | General and scoped token promises, SPA authorizations, request logs, visibility flag | | Content | Manage Time | Week drafts, dependent selections, validated hours and deferred writes/deletions |
| Content | Authentication/debug | General token promise, SPA authorizations, request records and visibility state |
| Background | Message dispatcher | Synchronously registered listener, asynchronous replies using `return true` | | 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 | 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 | Cookie/session recovery | Inactive tabs, polling/retry delays, cleanup in `finally` |
| Background | SPA operations | HTTP/JSON parsing, account/plan extraction and debug envelopes | | Background | SPA operations | HTTP/JSON parsing, account/plan extraction and debug envelopes |
| Background | Organization Chart storage | User/customer-scoped IndexedDB records and revision checks |
| Background | SR and time integration | Session preparation, resource lookup, service operations and header adaptation |
Closing the main modal hides it rather than destroying it. Many requests can 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 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. updates; they do not generally abort network traffic. Token Relay specifically
uses a 30-second timeout with AbortController when available. Its timeout also
covers response reading; debug persistence failures cannot leave it pending.
Some listeners have window guards; there is no general disposal lifecycle. Some listeners have window guards; there is no general disposal lifecycle.
## Authentication and API Routing ## Authentication and API Routing
Never substitute one token family for another or assume an opaque token is a JWT. 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. Normal authorization reuse and debug capture have different storage lifetimes;
see Storage Contracts before making claims about retained sensitive values.
| Credential | Acquisition | Consumers | Lifetime/recovery | | 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 | | XSRF | Background cookie lookup, then content `document.cookie` fallback | `x-xsrf-token` for Token Relay | Cookie recovery on missing/invalid response with a bounded refresh attempt |
| Fusion general | GET `/fscmRestApi/tokenrelay` | Opportunity POST and product GET Bearer | Content promise/cache; no expiry-based refresh equivalent to Sales Common | | Fusion general | GET `/fscmRestApi/tokenrelay` | Opportunity/saved-search, product, CRM SR and contact queries/writes | Shared content acquisition; 30-second request timeout |
| 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 | | 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, consumption trend, plan detail and rate card authorization | Same service response, distinct credential from ACCP-TS | | OCICONS-TS | SPA consumer service `uiaasHeader.value` | Account plans, consumption trend, plan detail and rate card authorization | Same service response, distinct credential from ACCP-TS |
| SPA-ORANGE | SPA consumer service `uiaasHeader.value` | Customer service requests, fixed `custsr-auth` header with raw value, no Bearer prefix; ignores `uiaasHeader.label` | Optional at consumer acquisition; required only when opening Service Requests; existing in-memory session rules | | SPA-ORANGE | SPA consumer service `uiaasHeader.value` | Customer service requests, fixed `custsr-auth` header with raw value, no Bearer prefix; ignores `uiaasHeader.label` | Optional at consumer acquisition; required only when opening Service Requests; existing in-memory session rules |
| SAIB-TS | SPA consumer service label/value | Installed Base after account registry resolution | Required when opening Installed Base |
| WWB-TS | SPA consumer service value | Workloads and Actions in `Wwb-Provider-Authorization` | Shared consumer acquisition; data caching is separately scoped |
| SR integration session | Integration cookies and `Authorization: Session` | Internal SR and Manage Time operations; catalog preparation | Required Origin and observed `x-appbuilder-client-id` where applicable; session preparation is separate from identity |
| Catalog authorization | SR catalog/token preparation | SR creation | Operation-specific headers; do not replace with the Fusion general token |
Fusion calls use `https://eeho.fa.us2.oraclecloud.com`; SPA calls use Fusion calls use `https://eeho.fa.us2.oraclecloud.com`; SPA calls use
`https://spa.oracle.com/oalcrm/web/api/`. Preserve `credentials: include` and `https://spa.oracle.com/oalcrm/web/api/`. Preserve `credentials: include` and
operation-specific headers rather than unifying all calls under a default token. operation-specific headers rather than unifying all calls under a default token.
Identity is validated before dependent data requests and then reused. Content
shares its pending identity promise; background shares by cookie store/private
context and can restore user id/name/roles from extension session storage. A page
reload alone therefore does not force identity revalidation. SALES_REP blocks
CRM activity data, badges and Manage Time entry points. Independent SR session
navigation can start concurrently before the role result is consumed.
SPA bootstrap uses
`https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=whitespace`.
Recovery rejects login/error/callback documents before a same-origin identity
probe. It closes owned tabs and preserves the originating window/container.
SR preparation uses the SeaaS_initiation external application, with a 70-second
timeout and manual retry. Internal SR detail recovery instead uses a hidden iframe
and bounded, shared 401 checks, not a visible tab or an unlimited request loop.
| Request | Method/path | Important contract | | 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 | | requestOpportunitiesSavedSearch | GET `/crmRestApi/searchResources/11.13.18.05/custom-actions/queries` | Opportunity entity; require one `isDefault=true` result with a valid savedSearchNumber |
| requestOpportunities | POST `/crmRestApi/searchResources/11.13.18.05/custom-actions/queries` | `copiedFrom=queries/<default savedSearchNumber>` across pages; RecordSet OR plus EffectiveDate AND; Preference transient; pages of 15 |
| requestSRSearchResources | GET `/crmRestApi/searchResources/latest/custom-actions/queries` | CRMServiceRequest entity; use the first item's savedSearchNumber |
| resquestCrmServiceRequest | POST `/crmRestApi/searchResources/latest/custom-actions/queries` | Dynamic copiedFrom; pending-delivery team SRs; all pages; incremental badge updates |
| 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 | | 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 | | 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}]` | | requestAccountSummary | POST `provider-proxy/g2m-account/service/accountsummary` | ACCP-TS; `[{key:"regId",value:partyNumber}]` |
| requestCustomerServiceRequest | GET `provider-proxy/g2m-custsr-provider/service/oscSr/{accountSummaryId}` | SPA-ORANGE; summary `id`, not `registryId`; encoded filter `{hierarchy:"single",status:"open"}`, creationDate descending, pages of 40 using totalResults/count/offset | | requestCustomerServiceRequest | GET `provider-proxy/g2m-custsr-provider/service/oscSr/{accountSummaryId}` | SPA-ORANGE; summary `id`, not `registryId`; encoded filter `{hierarchy:"single",status:"open"}`, creationDate descending, pages of 40 using totalResults/count/offset |
@@ -82,9 +127,23 @@ 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. 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. POST retry policies require operation-specific review; do not add generic retries.
After all opportunity pages arrive, the ready list is displayed before background
SR and product loading start. Completed product cycles use successful/reused SR
results before Workload Actions. Cached products can still require activity
network reads on cache miss. Failed SR/Action lookups remain unknown rather than
confirmed absent. Actions currently fetch only their first page and disclose
`hasNext`; contact opportunities and account workloads consolidate all pages.
Contact search uses Fusion `searchResources/latest` POST with a customer PartyId
criterion, keywords, Preference transient and the general Bearer. Contact
opportunities use the person's opaque PartyNumber in the GET filter, including
alphanumeric identifiers. Contact creation uses the selected customer's account
identifiers and an explicit Save; local hierarchy persistence is independent.
## Message Contracts ## Message Contracts
All names below have the `opportunitiesExtension.` prefix. Unknown messages return The examples below have the `opportunitiesExtension.` prefix; the complete name
inventory is in `src/shared/messages.js`. Unknown messages return
false without a response. Supported background messages return true immediately false without a response. Supported background messages return true immediately
and later call sendResponse. `platform/background-api.js` centralizes the existing and later call sendResponse. `platform/background-api.js` centralizes the existing
callback/promise differences. `platform/content-messages.js` retains the original callback/promise differences. `platform/content-messages.js` retains the original
@@ -116,25 +175,94 @@ Do not expose a caller-provided arbitrary request URL through the background.
| 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-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-theme` | `light` or `dark` |
| Page localStorage: `opportunities-extension-opportunity-type-preferences` | JSON object: opportunityTypeValues, opptyProductsCacheHours | | Page localStorage: `opportunities-extension-opportunity-type-preferences` | JSON object: opportunityTypeValues, opptyProductsCacheHours |
| Page localStorage: `opportunities-extension-table-sort-v1-<table>` | Sort key and direction, independently per supported table; memory fallback if storage is unavailable |
| Page localStorage: `opportunities-extension-manage-time-reference-date` | Valid selected date; falls back to today |
| Page localStorage: `opportunities-extension-debug:<tabId>:<id>` and `:index` | Full captured request records and index; may retain sensitive originals; quota failures fall back to memory |
| Page sessionStorage: `opportunities-extension-debug-tab` | Namespace identifier for debug records |
| Page sessionStorage: `opportunitiesExtension.shortcutIntent` | Early bookmark timestamp; consumed once and expires after ten minutes |
| Extension storage.local: `opportunitiesExtension.opptyProductsCache.v1` | Object indexed by OptyNumber, each entry `{fetchedAt,items}` | | Extension storage.local: `opportunitiesExtension.opptyProductsCache.v1` | Object indexed by OptyNumber, each entry `{fetchedAt,items}` |
| Extension storage.local: `opportunitiesExtension.workloadsCache.v1` | Workload data by normalized principal/customer; `{fetchedAt,items}`; no persistent entry without a principal |
| Extension storage.local: `opportunitiesExtension.activities.v1:<scope>` | SR/Action records scoped by principal, kind and identifier; SR keys also include product-update timestamp |
| Extension storage.local: `opportunitiesExtension.activities.invalidatedAt.v1` | Invalidation timestamp preventing obsolete activity snapshots from being reused or rewritten |
| Extension storage.session: `opportunities-extension-current-user:<context>` | Validated `{id,name,roles}`, partitioned by cookie store/private context; no identity token in this cache |
| Extension storage.session: `opportunitiesExtension.resourceUsers.v1` | Resource user, email and fetchedAt; shared concurrent reads and TTL validation |
| Extension storage.session: `opportunitiesExtension.srAppBuilderClientId.v1` | Observed integration origin/client ID/timestamp; one-hour validity |
| Extension-owned IndexedDB: `opportunities-extension-organizations`, store `charts` | Database version 3; user/customer key, record schemaVersion 1, revision, updatedAt and normalized nodes |
| Page memory: form request cache | Allowlisted read results, shared in-flight promises, maximum 500 entries, scoped to current user |
| Page memory: active views and authentication | Working charts, transient contact email, week drafts, opportunity results, token promises and service authorization values |
Product TTL defaults to 24 hours and is capped at 30 days in background. Storage Product TTL defaults to 24 hours and is capped at 30 days by the background cache.
failures are tolerated for the active view. Recreating background reloads stored Activity TTL uses the Preferences duration, also capped at 30 days, anchored to
products but does not restore in-flight jobs. Cache TTL does not physically delete the product-update timestamp rather than response time. SR snapshots preserve
expired entries. Account/container isolation limitations are tracked separately. different update timestamps; expired SR snapshots are pruned on subsequent writes.
The form request cache defaults to 24 hours and caps its duration at seven days;
it passes that duration to supported operations, including workload/resource reads.
Workload reuse preserves the original timestamp instead of restarting the TTL.
The product cache is keyed by opportunity number, not user or container. Do not
extend the stronger principal/customer scoping of workload and activity caches to
that cache by assumption. Identity caching has browser-session lifetime and its
own cookie-store/private-context partition, with memory fallback where extension
session storage is unavailable. Browser storage APIs are distinct from page storage.
Form cache reuse excludes writes and token generation. Successful supported writes
invalidate reusable form reads; concurrent reads share promises and suppress
duplicate debug envelopes. Refresh all invalidates related product, workload,
activity and form data before reloading Reference, while preserving preferences
and authentication. Generation checks prevent old reads from restoring invalidated
snapshots. Invalidation failure is surfaced rather than silently accepted.
Organization-chart nodes retain id, parentId, name, jobTitle, notes, collapsed,
optional contactPartyId and alphanumeric PartyNumber. Email is excluded; older
stored email fields are removed during migration. Chart storage is isolated by
normalized user/customer and browser profile, enforces revision checks, and has no
cross-device sync. Clearing a Fusion page's IndexedDB does not clear the extension's
IndexedDB, and an already-open working chart can still exist in page memory.
Storage failures have feature-specific behavior: preferences and data caches can
fall back to current memory/results, debug keeps records in memory with feedback,
and chart persistence reports failure rather than claiming a durable save.
Recreating the background loses in-flight jobs while restoring supported stored
data on demand. Expiration does not imply that every cache physically deletes its
old records immediately.
## UI Contracts ## UI Contracts
### Debug lifecycle
`features/debug/state.js` stores each changed request under the current tab's
localStorage prefix and maintains an index, batching writes with a short timer.
Opening Debug Requests reloads saved records while retaining handles used by
in-flight requests. Hidden debug does not construct request-detail DOM; opening
renders summaries and expanding a record mounts details. Storage exceptions keep
the memory records usable and do not block the recorded network operation.
`services/fusion/opportunities.js` resets debug immediately before sending page
one, after resolving the saved search. Thus earlier authentication and saved-search
logs can disappear at that boundary. Pagination does not reset history, and closing
the main modal does not delete it. Reset clears only the current tab prefix and
invalidates previous-generation completions, preserving preferences and other tabs.
The default UI masks sensitive values and the explicit visibility toggle can show
retained originals, including tokens in captured responses. Individual transports
also mask selected headers, but that is not a universal redaction guarantee.
localStorage records can contain sensitive values; display masking is neither
encryption nor protection from other code with access to that page's storage.
The normal identity cache excludes its token, which does not imply the debug
response excludes it. This documentation change does not alter retention policy.
### Shared interactions
Preserve labels/IDs/classes, column order, URLs, clipboard behavior, sort/filter Preserve labels/IDs/classes, column order, URLs, clipboard behavior, sort/filter
semantics, formatting, theme colors, keyboard shortcuts and row expansion. semantics, formatting, theme colors, keyboard shortcuts and row expansion.
WON includes WON_PENDING without rewriting StatusCode. Owner uses first/last name WON includes WON_PENDING without rewriting StatusCode. Owner uses first/last name
with the existing fallbacks. Changing reference resets customers/owners to all; with the existing fallbacks. Changing reference resets customers/owners to all;
closing/reopening preserves search. Fiscal years begin in June; RENEWALS currently closing/reopening preserves search. Fiscal years begin in June; the active
has no date range and therefore does not load opportunities. Reference choices use supported fiscal/date ranges rather than a RENEWALS view.
Preserve SVG chart data normalization, animation, tooltips and series selection. Preserve SVG chart data normalization, animation, tooltips and series selection.
AI text is inserted with textContent. Debug masking and DOM isolation have known Treat displayed API text as data. Debug masking and DOM isolation have limitations;
limitations; see the separate security backlog rather than assuming full redaction. do not equate display masking with full redaction or encryption at rest.
## Module Ownership and Dependencies ## Module Ownership and Dependencies
@@ -146,8 +274,9 @@ to isolated tests, not published on the page/global object in production.
Factories do not import or instantiate peers. Features receive service/auth ports; Factories do not import or instantiate peers. Features receive service/auth ports;
services/auth receive transport/platform ports; pure models receive data. The services/auth receive transport/platform ports; pure models receive data. The
composition roots supply callback notifications for UI updates and debug events, composition roots supply callback notifications for UI updates and debug events,
preserving existing interactions without module-import cycles. Shared messages preserving existing interactions without module-import cycles. Shared messages,
are the only source included in both contexts, with distinct instances/state. date/debug utilities and the organization-chart model can be included in both
contexts, with distinct instances/state.
Some stateful UI collaborators intentionally share a feature-owned state object; Some stateful UI collaborators intentionally share a feature-owned state object;
these objects are created per application, never stored in a global registry. these objects are created per application, never stored in a global registry.
@@ -158,11 +287,12 @@ these objects are created per application, never stored in a global registry.
| fiscal dates/query payload | `features/opportunities/fiscal-periods.js`, `services/fusion/opportunities.js` | Pure date rules vs HTTP operation | | 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 | | table/search/filters/formatting/dashboard | `features/opportunities/` | Related presentation responsibilities, explicit state |
| saved settings | `features/preferences/storage.js`, `view.js` | Serialization/fallbacks vs dialog | | 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 | | token families | `auth/content-session.js`, `current-user.js`, `token-relay.js`, `spa-credentials.js`, `sr-request-headers.js` | Different credentials and lifetime policies |
| cookie lookup/recovery, SPA session | `auth/xsrf-*.js`, `consumer-session.js` | Privileged recovery separate from UI | | 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 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 | | 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 | | org charts and contacts | `features/org-chart/`, `services/fusion/account-contact.js` | Local hierarchy/storage vs Oracle contact operations |
| Manage Time | `features/manage-time/`, `services/fusion/time-entries.js` | Weekly UI drafts vs session-authenticated reads/writes |
| consumption | `features/consumption/`, `services/spa/` | Plans, chart model/view/controller vs HTTP operations | | 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 | | 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 | | background dispatcher | `background/messages.js`, `shared/messages.js` | Stable serializable message contracts |
@@ -170,7 +300,7 @@ these objects are created per application, never stored in a global registry.
The background composition deliberately has no DOM dependency. Listener registration The background composition deliberately has no DOM dependency. Listener registration
is synchronous, before any storage read or request. Worker restart reconstructs is synchronous, before any storage read or request. Worker restart reconstructs
volatile state and lazily reads the existing product cache; tokens are not persisted. volatile state and lazily restores supported product, identity and session metadata.
Content effects (DOM, fetch, storage, animation) enter through the environment at Content effects (DOM, fetch, storage, animation) enter through the environment at
the composition root. localStorage access stays lazy to retain blocked-storage the composition root. localStorage access stays lazy to retain blocked-storage
fallbacks. Tests may inject effects or individual callback ports without changing fallbacks. Tests may inject effects or individual callback ports without changing
@@ -178,7 +308,8 @@ source strings. Time-sensitive modules are tested with isolated clocks.
## Adding a Feature ## Adding a Feature
1. Define its message/API/storage contract and synthetic expected behavior first. 1. Identify the affected OpenSpec capabilities and propose the behavior delta,
message/API/storage contract and synthetic expected behavior first.
2. Add a factory under the owning feature, or a service under the correct host. 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. 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 3. Add its source path to the appropriate array in `scripts/sources.cjs`. Source
@@ -201,8 +332,6 @@ belong in the consumption feature and SPA service, not the bootstrap.
## Source vs Generated Code ## Source vs Generated Code
Distribution `content.js` and `background.js` are generated bundles and may remain 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. large. Build serialization uses JSON.stringify for CSS, retains quotes,
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 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. fixtures now load composed scripts normally; they no longer eval rewritten sources.

View File

@@ -12,7 +12,7 @@ pass until executed and recorded separately for each browser.
- Run `node scripts/build.mjs --check` and `node scripts/test.cjs` first. - Run `node scripts/build.mjs --check` and `node scripts/test.cjs` first.
- Load `dist/chromium` using Load unpacked in Chrome/Edge, or load - Load `dist/chromium` using Load unpacked in Chrome/Edge, or load
`dist/firefox/manifest.json` as a temporary Firefox add-on. Verify the source `dist/firefox/manifest.json` as a temporary Firefox add-on. Verify the source
directory and version 1.0.5; avoid testing a previously installed stale copy. directory and version 1.0.7; avoid testing a previously installed stale copy.
- Keep debug secrets hidden. Record only status codes, request names, durations - Keep debug secrets hidden. Record only status codes, request names, durations
and sanitized errors. Do not export real tokens, response bodies or account data. 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 - AI generation and tests requiring a deliberately expired session must use an
@@ -34,6 +34,8 @@ pass until executed and recorded separately for each browser.
| Consumption | Dialog opens promptly, weekly chart by default; plan/period changes and series filters work | | 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 | | 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 | | Debug | Expected operation names/statuses; sensitive values remain hidden |
| Organization charts | Icon after customer SR action; add/report/reparent/edit/notes/collapse; Save chart then reopen from another opportunity for the same customer; reload page and restart browser retains chart; theme and small viewport remain usable |
| Organization chart conflicts | Two tabs open the same chart; save one, then save stale second tab; second save reports a conflict and retains local edits rather than overwriting the first |
| Background restart | Close its inspector, allow suspension, then trigger a message; listener responds and stored product cache is reloaded | | 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 | | 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 | | Firefox containers | Test only in authorized containers; record current cookie/cache isolation limitations without silently changing behavior |

View File

View File

@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-10-31
skip_specs: true

View File

@@ -0,0 +1,100 @@
# Design
## Context
See [proposal.md](proposal.md) for motivation. The current config selects `spec-driven` and contains commented examples only. README documents workflow and usage; `docs/architecture.md` documents runtime boundaries; the twelve specs are the reviewed behavioral baseline; RELEASE_NOTES records releases.
Read-only inspection covered those documents, `package.json`, `scripts/build.mjs`, `scripts/test.cjs` and the installed OpenSpec configuration parser. The installed CLI accepts `context` as a string, `rules` as artifact-keyed arrays of strings and `operations.apply/archive.guidance` as arrays of strings. No custom schema, store or dependency is necessary. Design is included to make the exact configuration and its cross-workflow effects reviewable before apply.
## Goals / Non-Goals
**Goals:** Make durable conventions available to future planning and implementation; separate shared context, artifact rules and advisory execution guidance; keep the candidate valid for the installed CLI.
**Non-Goals:** Turn config into a second README, freeze current endpoint URLs or module inventories, introduce executable policy enforcement, or make archive mean release. No main specification needs modification.
## Decisions
### Keep the standard schema and local root
Retain `schema: spec-driven`. Do not add a `store`, custom workflow or schema override: the existing local workflow already supports proposal/design/tasks and explicit spec skipping for tooling-only changes.
### Keep only stable constraints in shared context
Use a short context with documentation references, English output conventions, browser compatibility and the user's release expectations. Detailed APIs, role gates, storage keys, module layouts, test counts and URLs stay in the existing documents. This avoids a copied baseline drifting independently.
English artifacts and UI do not require English conversation: communication can continue in Portuguese. The Firefox-only XPI expectation applies when a release is requested; it does not drop Chromium/Edge support or authorize a release during ordinary work.
### Separate artifact guidance from operation guidance
`rules` describe the content of proposal, design, specs and tasks. `operations` hold apply/archive advice and do not override explicit choices, skill steps or CLI checks. Hardcoding developer-machine paths or adding redundant approval gates was considered and rejected.
### Proposed replacement for openspec/config.yaml
This block is the reviewed candidate, not an applied configuration. Replace the config with it only during apply.
```yaml
schema: spec-driven
context: |
Opportunities Extension supports Oracle Fusion workflows in Chromium/Edge and Firefox.
Write OpenSpec artifacts and extension-authored UI text in English; preserve service-provided names.
Use openspec/specs/ for behavioral contracts, docs/architecture.md for execution and storage
boundaries, README.md for setup and usage, and RELEASE_NOTES.md for release history.
Preserve both browser targets and the existing extension design system unless the change
explicitly proposes a compatibility or presentation change.
When a release is requested, include a valid Firefox XPI; XPI packaging is Firefox-only.
Keep the README main title as Opportunities Extension and release history in RELEASE_NOTES.md.
OpenSpec archival and product release are separate operations.
rules:
proposal:
- Identify affected existing capabilities and distinguish observed behavior from proposed changes.
- State scope, non-goals and compatibility impact; do not introduce product requirements for tooling-only changes.
design:
- Reference the relevant architecture contracts instead of duplicating their inventories.
- For affected integrations, describe request ordering, credentials, pagination, retries and stale-response handling.
- For affected persistence, describe ownership, identity scope, invalidation and migration or rollback needs.
- For UI changes, identify shared components and relevant loading, empty, failure and theme behavior.
specs:
- Describe observable behavior with testable scenarios; keep implementation steps and module wiring in design and tasks.
- Use synthetic identifiers and examples; never embed real tokens, cookies or personal contact data.
tasks:
- Tie each task to a concrete deliverable and include verification proportional to the affected behavior.
- For source changes, include relevant synthetic tests, regeneration of affected browser outputs and a build consistency check.
- Record installed-browser and live Oracle checks separately from synthetic tests, including checks not performed.
- Include version, release notes and Firefox XPI work only when release delivery is part of the requested scope.
operations:
apply:
guidance:
- Edit source modules and regenerate distributions through the build script; do not hand-edit generated bundles.
- Use synthetic data for automated checks and avoid incidental writes to live Oracle services during validation.
- Run focused checks for the change; use node scripts/test.cjs for integration verification of runtime changes and node scripts/build.mjs --check for generated-output consistency.
- Report what changed, verification results and material limitations without claiming unperformed browser or live-service validation.
archive:
guidance:
- Check documentation links when moving a change and preserve the distinction between archived evidence and current specifications.
- Summarize task completion, specification synchronization and the final archive location; do not treat archival as a product release.
```
### Validation approach
Parse the candidate as YAML and validate it with the installed OpenSpec configuration schema before apply. The context must be a nonempty UTF-8 string below 51,200 bytes; all artifact rule and operation guidance entries must be nonempty strings. Use only supported artifact and operation identifiers.
After apply, inspect `openspec context --json` and `openspec instructions proposal/design/tasks/apply/archive --change configure-project-context --json` as separate calls. Confirm context is delivered, artifact rules occur only on their matching artifact, and apply/archive advice is exposed as `operationGuidance`. For specs rules, inspect the parsed configuration rather than creating fictitious delta files or removing `skip_specs` just to probe a skipped artifact.
Run `openspec validate configure-project-context --strict`, `openspec validate --specs --strict` and `git diff --check`. A config-only change does not justify rebuilding distributions or running all runtime tests. Preserve baseline runtime verification as historical evidence, not a new test claim.
## Risks / Trade-offs
- [Context becomes stale] -> Keep it short and reference existing authoritative files; omit current version, endpoint lists, absolute paths and test totals.
- [Guidance is mistaken for enforcement] -> Explain that config is prompt-level guidance; it does not guarantee tests, safe storage or API authorization.
- [New rules overconstrain unrelated work] -> Scope design rules to affected integrations/persistence/UI and verification to the change's impact.
- [A future CLI changes supported fields] -> Validate parsing and actual instruction delivery with the installed CLI when applying or upgrading.
- [Release expectations are misread as authorization] -> Keep release tasks conditional and explicitly separate archive from release.
## Migration Plan
During apply, replace only `openspec/config.yaml` with the candidate, verify configuration consumption and complete the tracked tasks. Existing specs, archived artifacts and runtime data need no migration. Future instruction reads receive the new guidance; existing artifacts are not rewritten automatically.
Rollback restores the prior config from the implementation diff without deleting changes or specs. No runtime deployment, data migration or browser reinstallation is required.

View File

@@ -0,0 +1,30 @@
# Proposal
## Why
The behavioral baseline is now archived, but OpenSpec configuration still contains only the default schema and commented examples. Future changes need concise, durable project constraints and artifact guidance without repeatedly reconstructing decisions from conversation history or duplicating the twelve specifications.
## What Changes
- Keep `schema: spec-driven` and the repository-local planning root.
- Add a short English `context` capturing documentation ownership, English UI/artifacts, browser compatibility, established presentation conventions and the Firefox XPI release expectation.
- Add focused `rules` for proposal, design, specs and tasks; keep implementation guidance in the supported `operations.apply` and `operations.archive` sections.
- Provide the exact proposed YAML in the design for review before replacing `openspec/config.yaml` during apply.
- Validate configuration parsing and instruction delivery without changing extension behavior.
## Capabilities
### New Capabilities
None. This change configures development tooling rather than introducing product behavior.
### Modified Capabilities
None. The twelve existing capabilities remain unchanged. This tooling-only change declares `skip_specs: true`; no delta specification is required or generated.
## Impact
- Implementation target: `openspec/config.yaml` only, plus this change's completion records.
- OpenSpec agents receive project context, per-artifact rules and advisory apply/archive guidance.
- No source, browser permissions, API contracts, storage data, generated distributions, dependencies, version numbers or release history change.
- Out of scope: new workflows/schemas, external stores, runtime changes, releasing a version, generating an XPI now, or automatically applying/archiving future changes.

View File

@@ -0,0 +1,15 @@
# Tasks
## 1. Apply the reviewed configuration
- [x] 1.1 Replace `openspec/config.yaml` with the YAML candidate in design.md, preserving `schema: spec-driven`; verify its parsed values match the candidate, context is below 51,200 UTF-8 bytes, rules and guidance are nonempty string arrays, and only supported artifact/operation keys are used. Record the configuration validation result in the change.
- [x] 1.2 Confirm instruction delivery using `openspec context --json` and separate `openspec instructions` calls for proposal, design, tasks, apply and archive on this change; verify the root is unchanged, context is present, artifact rules match their artifact and operation advice is separate. Verify specs rules in parsed config without creating delta files for this `skip_specs` change; record results and the advisory nature of config guidance.
## 2. Verify scope and integration
- [x] 2.1 Run `openspec validate configure-project-context --strict` and `openspec validate --specs --strict`; verify the change is valid with no deltas and all twelve existing capabilities remain valid.
- [x] 2.2 Run `git diff --check` and compare the final changes against the apply starting state; confirm only config and this change's completion records changed, no machine-specific paths or live credentials were introduced, and no runtime tests/build/browser checks are claimed as performed for this config-only update.
## Workflow follow-up
- Archive only after application and review; this change has no delta specs to synchronize and does not constitute a release.

View File

@@ -0,0 +1,8 @@
# Application Verification
- Configuration parsed with the installed OpenSpec YAML parser and ProjectConfigSchema. The applied values match the design candidate exactly.
- Schema remains spec-driven; context is 762 UTF-8 bytes. All four artifact rule groups and both operation guidance groups contain nonempty strings and supported keys.
- CLI context and separate proposal/design/tasks/apply/archive instruction lookups passed. Root is unchanged; context matches after the artifact loader's documented trim normalization; artifact rules match their artifact and operationGuidance remains separate. Specs rules were checked in parsed config; no delta files were created.
- Configuration advice is prompt-level guidance, not executable enforcement. Source regeneration and runtime test guidance are inapplicable to this configuration-only edit; no source or generated output was changed. No Oracle requests were sent.
- Strict validation passed for configure-project-context with zero deltas (skip_specs), and for all twelve existing main specifications.
- Final scope review and git diff --check passed. Relative to the initial clean tracked state plus the preexisting change directory, edits are confined to openspec/config.yaml, task completion markers and this verification record. No machine-specific paths or live credentials were added to config. Runtime tests, builds, installed-browser checks and live-service checks were not run because no runtime behavior or generated output changed.

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-31

View File

@@ -0,0 +1,166 @@
# Design
## Context
See proposal.md for motivation and the capability inventory. This is the initial specification baseline for the current checkout, whose package/build version is 1.1.0. The checkout includes behavior added after the 1.1.0 release; the baseline must not be presented as an exact description of that tag.
OpenSpec uses the existing `spec-driven` schema. Its main specification directory is empty. The change introduces behavioral contracts, not runtime features. A design is warranted because reconciling documentation spans authentication, data loading, storage and UI boundaries and requires explicit decisions about conflicting historical descriptions.
The application uses factory-based JavaScript modules. `src/content/application.js` and `src/background/application.js` compose collaborators; `scripts/sources.cjs` and `scripts/compose.cjs` assemble isolated classic scripts. `scripts/build.mjs` generates Manifest V3 distributions for Chromium/Edge and Firefox with different background entry points. No framework migration is involved.
## Goals / Non-Goals
**Goals:**
- Preserve externally observable behavior in requirements with testable scenarios.
- Make source/test evidence discoverable without putting internal function names into normative requirements.
- Reconcile README and architecture descriptions with current behavior during application.
- Establish the distinction between a proposed baseline in this change and durable specifications promoted after review and completion.
**Non-Goals:**
- Fix unrelated runtime defects, add retries, migrate storage, alter permissions or change endpoints.
- Declare every scenario already covered by automated or live-browser tests.
- Copy the full README into configuration, customize the OpenSpec schema, or add machine-specific executable paths as project requirements.
- Change release history, regenerate packages for documentation edits, or publish a version.
## Decisions
### 1. Bootstrap existing capabilities explicitly
The specs use `ADDED Requirements` because the OpenSpec inventory is empty. This means new specification coverage, not newly implemented product behavior. Each spec has its own Purpose and cohesive domain boundary. A documentation-only `skip_specs` change would not fulfill the user's request to establish functional artifacts, while a single all-project spec would make future deltas difficult to review.
### 2. Resolve documentation conflicts against observable implementation
Use current source plus relevant regression scenarios to determine the baseline. Historical release notes explain evolution; they do not override the current implementation. Tests are supporting evidence, not proof of live Oracle compatibility. Unverified aspirations must not be silently converted into requirements or implemented during baseline application.
### 3. Preserve runtime and storage distinctions
Document content/background messaging and browser API adaptation without changing them. Distinguish page localStorage, sessionStorage, extension storage, in-memory caches and extension-owned IndexedDB. In particular, debug display masking does not guarantee that stored records exclude credentials. A blanket statement that all production credentials remain only in memory would contradict the retained debug response behavior.
The principal flow to describe is:
```text
identity and session preparation
|
v
Token Relay --> Opportunity saved search --> opportunity pages
|
v
display ready list
|
+-------------------------+------------------+
v v
CRM SR background pages product enrichment
| |
+-------- SR success/reuse + products -------+
|
v
Workload Actions
```
Workloads additionally depend on account/product identifiers. This diagram does not prescribe a new global request scheduler. Profile gates apply to the data operations they control; concurrent session bootstrap is not itself a Manage Time data request.
### 4. Keep documentation responsibilities separate
- README.md: concise product overview, supported entry points, major features, development/build/test/loading instructions and links to deeper documentation.
- docs/architecture.md: execution contexts, transport/credential boundaries, storage ownership, actual permissions and verification limits.
- RELEASE_NOTES.md: historical release record; preserve prior descriptions as history rather than rewriting old releases to match today.
- `openspec/specs/`: durable behavioral contracts after the baseline is reviewed, applied and archived.
- This change: proposal, design, initial delta specs and actionable documentation/verification tasks.
Replacing the README entirely with OpenSpec would make ordinary setup less accessible. Duplicating complete behavioral contracts in both places would make future drift more likely. Link from documentation instead, retaining only the usage detail needed by its audience.
### 5. Use minimal configuration
Retain `openspec/config.yaml` as initialized. Discoverable code structure and local CLI locations do not need to become project context rules. All artifacts in this change are English to match the extension documentation; discussion with the user remains Portuguese. Any later configuration customization is a separate explicit decision.
## Evidence Map
Paths below are relative to the repository root. These identify where to review behavior, not a claim that every specification scenario has already been executed.
| Capability | Primary source evidence | Existing regression evidence |
| --- | --- | --- |
| session-authentication | `src/auth/current-user.js`, `src/auth/consumer-session.js`, `src/auth/spa-page.js`, `src/background/contracts.js`, `src/content/application.js` | `tests/currentUser.test.cjs`, `tests/spaSessionWait.test.cjs`, `tests/spaSessionReload.test.cjs`, `tests/srSessionPreparation.test.cjs` |
| opportunity-exploration | `src/services/fusion/opportunities.js`, `src/features/opportunities/controller.js`, `filters.js`, `table.js`, `fiscal-periods.js` | `tests/dependentFilters.test.cjs`, `tests/allOpportunityFilters.test.cjs`, `tests/opportunityTable.test.cjs`, `tests/dashboardTotals.test.cjs`, `tests/characterization.test.cjs` |
| opportunity-calendar | `src/features/opportunities/calendar.js`, `src/features/opportunities/styles.css` | `tests/opportunityCalendar.test.cjs` plus installed-browser visual inspection for week positioning |
| product-workloads | `src/features/products/details.js`, `workloads.js`, `workloads-cache.js`, `ramp-projection.js`, `forecast-update.js` | `tests/workloads.test.cjs`, `tests/ramp-projection.test.cjs`, `tests/workload-forecast-update.test.cjs`, `tests/badgeIcons.test.cjs` |
| service-requests | `src/services/fusion/crm-service-requests.js`, `src/features/opportunities/crm-service-requests.js`, `crm-sr-form.js`, `internal-sr-session.js`, `internal-sr-form.js`, `src/features/service-requests/` | `tests/srRequestPriority.test.cjs`, `tests/crmServiceRequests.test.cjs`, `tests/activityProgress.test.cjs`, `tests/internalSrRecovery.test.cjs`, `tests/srCreate.test.cjs`, `tests/serviceRequests.test.cjs` |
| workload-actions | `src/features/opportunities/product-activities.js`, `workload-actions.js`, `workload-action-form.js` | `tests/productActivities.test.cjs`, `tests/workloadActions.test.cjs`, `tests/workloadActionForm.test.cjs` |
| manage-time | `src/features/manage-time/view.js`, `src/services/fusion/time-entries.js`, `src/content/application.js` | `tests/manageTime.test.cjs`, `tests/currentUser.test.cjs` |
| customer-insights | `src/features/install-base/`, `src/features/consumption/`, `src/features/ratecard/`, `src/services/spa/` | `tests/installBase.test.cjs`, `tests/consumptionTrend.test.cjs`, `tests/consumptionPlanDetail.test.cjs`, `tests/ratecard.test.cjs` |
| organization-charts | `src/features/org-chart/model.js`, `storage.js`, `view.js`, `src/services/fusion/account-contact.js` | `tests/orgChart.test.cjs`, `tests/accountContact.test.cjs` |
| extension-shell | `src/content/fusion-page.js`, `shortcut-intent.js`, `modal.js`, `dialog-drag.js`, `src/features/opportunities/print.js` | `tests/shortcutLink.test.cjs`, `tests/bookmarkShortcut.test.cjs`, `tests/bookmarkHint.test.cjs`, `tests/modalShortcuts.test.cjs`, `tests/dialogDrag.test.cjs` |
| preferences-cache | `src/features/preferences/`, `src/features/opportunities/activity-cache.js`, `refresh-all.js`, `src/features/products/cache.js` | `tests/tableSorting.test.cjs`, `tests/activityCache.test.cjs`, `tests/formRequestCache.test.cjs`, `tests/refreshAll.test.cjs` |
| request-debugging | `src/features/debug/state.js`, `content-log.js`, `content-view.js`, `src/services/fusion/opportunities.js` | `tests/debugStorage.test.cjs`, `tests/debugTabs.test.cjs`, `tests/productsDebug.test.cjs` |
## Documentation Reconciliation
During application, make the following concrete corrections, verifying the cited implementation again before editing:
| Document/topic | Required reconciliation |
| --- | --- |
| README Opportunity Views | Replace month/week navigation and overflow links with Reference-bounded scrolling months, all daily cards and current-week positioning. |
| README and architecture AI references | Remove descriptions of active AI summaries, Sales Common token acquisition and AI suites/modules. Preserve historical release notes. |
| README authentication | Use the current SPA UI bootstrap URL; distinguish successful identity reuse from session recovery attempts. |
| README SR/Actions ordering | Describe opportunities displayed first, progressive SR enrichment and Actions depending on product identifiers and SR success/reuse. Remove the assertion that SR must wait for product completion. |
| README activity cache | Reconcile claims that cached products never allow activity network requests: the current product-activity orchestration permits cache-miss lookups. Retain timestamp/principal cache scoping where implemented. |
| README access restrictions | Include Manage Time UI and data gates for SALES_REP. Do not claim that all independent SR session bootstrap is disabled by that role. |
| README Organization Charts | Include alphanumeric PartyNumber, on-demand contact refresh, transient email, contact creation and fully paginated contact opportunities. |
| README capabilities | Cover dependent Customer/Owner options, Booking/Workload filters, printing, bookmark shortcuts/hints and draggable headers. |
| README Rate Card | Document Order Number multiselect and inspect final composed styles before describing responsive width; a single earlier CSS min-width rule is not proof of the effective layout. |
| Architecture contexts/permissions | Reflect the early shortcut-intent script, SPA readiness script and generated manifest permissions/hosts from `scripts/build.mjs`, including bookmarks and SR integration access. |
| Architecture request lifecycle | Remove the blanket claim that no AbortController is used; distinguish Token Relay timeouts from stale-response suppression and requests continuing behind a hidden modal. |
| Architecture storage | Describe debug localStorage and its reset boundary, preferences/sort storage, activity/product/workload caching, identity/session reuse and org-chart IndexedDB. Distinguish UI masking from storage confidentiality. |
| Architecture legacy contracts | Remove active RENEWALS and AI contracts; explain dynamic saved-search selection for Opportunity and CRM SR independently. |
## Risks / Trade-offs
- [A baseline can accidentally promise unimplemented behavior] -> Trace each requirement to source and scenarios; raise discrepancies rather than adding runtime fixes under this documentation change.
- [Documentation already contains contradictory cache and session claims] -> Review the composition root and effective call paths, not just an isolated helper or old prose.
- [Synthetic tests cannot verify Oracle endpoint availability or browser SSO] -> Record automated validation separately from installed-browser/live-service verification; never label the latter passed without execution.
- [Debug records can contain sensitive values] -> Describe the current behavior accurately without embedding real tokens, customer responses or personal data in artifacts. Any storage-hardening change is separate work.
- [Broad baseline coverage can obscure ownership] -> Keep twelve cohesive capability files and a source/test evidence map; do not duplicate implementation details in requirements.
## Migration Plan
1. Review the proposed contracts against the current checkout and resolve documentation discrepancies without changing runtime behavior.
2. Apply the README and architecture corrections; keep the existing principal README title and separate release history.
3. Validate the OpenSpec change strictly, verify build consistency and run the relevant existing synthetic checks. Record any failures or browser-verification gaps honestly.
4. Complete the documentation tasks, then use the archive workflow to promote the initial specs to `openspec/specs/`. Do not manually copy them early or mark the change applied merely because planning is complete.
5. For subsequent behavior changes, identify the affected baseline capability, create delta requirements, implement and verify, then archive.
No runtime deployment or data migration is required. Before archival, rollback consists of reverting the documentation edits and removing the proposed change through an explicit repository operation. After archival, restore/revise documentation and specifications together through a follow-up change; never delete user data or rewrite release history as part of this baseline.
## Application Review Record
Reviewed on 2026-10-31 against the current checkout:
- All twelve proposed capabilities have a matching specification file. Structural review found 69 requirements and 88 scenarios; every requirement has a scenario with a trigger and outcome.
- README.md and docs/architecture.md now distinguish opportunity-first loading, background SR progress, product-dependent Actions and cache-miss activity reads from the superseded loading descriptions.
- The documentation distinguishes page storage, extension storage and chart IndexedDB; it explicitly states that debug masking does not remove retained originals from storage.
- Review confirmed the current SPA bootstrap, role gates, dynamic saved searches, contact identifiers and composed Rate Card styles without changing their implementation. No additional runtime behavior was introduced to satisfy the baseline.
- Task-specific synthetic suites passed as their documentation groups were applied. Local documentation links resolve.
- No installed-browser visual test or live Oracle authentication/read/write was performed. Calendar sizing, responsive layouts, dragging, print pagination and live endpoint availability retain that verification limitation; synthetic scenario coverage is not a claim of end-to-end certification.
Final application checks on 2026-10-31:
- `openspec validate document-project-baseline --strict`: passed.
- `node scripts/build.mjs --check`: passed; existing Chromium/Edge and Firefox outputs match the source.
- `node scripts/test.cjs` using Node 22.15.0: 54 test files, 462 tests passed, zero failed.
- `git diff --check` and a whitespace scan of the new OpenSpec Markdown files: passed.
- The tracked diff contains only README.md and docs/architecture.md. The change's task/review records are updated in the already-created OpenSpec tree; the preexisting untracked `.agents/` installation is untouched.
- Source, tests, build scripts, distributions, package version, release history and OpenSpec configuration are unchanged. The change is applied but not archived; the main spec inventory remains unpopulated until the separate archive workflow.
## Archive Record
On 2026-10-31, after explicit approval to synchronize and archive, all twelve
capabilities were promoted to `openspec/specs/`. Their authored Purpose sections,
69 requirements and 88 scenarios were preserved; the main files use canonical
Specification titles and Requirements sections without delta-operation headers.
All twelve main specifications passed `openspec validate --specs --strict`.
The completed change (13/13 tasks, schema `spec-driven`) was moved to
`openspec/changes/archive/2026-10-31-document-project-baseline/`, including its
`.openspec.yaml`. README and architecture navigation now point to the durable
specifications and archived evidence. This archive record supersedes the
pre-archive inventory status in the application checks above.

View File

@@ -0,0 +1,41 @@
# Proposal
## Why
OpenSpec has been initialized without capability specifications, while the README and architecture document still describe several superseded behaviors. Establish an evidence-backed baseline so future changes can state precisely which existing contracts they preserve or modify.
## What Changes
- Introduce initial specifications for existing behavior, using the current source and regression tests as evidence. These are new OpenSpec records, not new product features.
- Record authentication boundaries, asynchronous loading order, role restrictions, persistence, and user interactions as requirements with observable scenarios.
- Plan corrections to README.md and docs/architecture.md, including Calendar, removed AI summaries, SPA navigation, SR loading, Manage Time access, contact identifiers, permissions, and debug storage.
- Keep README.md as the product/development entry point and RELEASE_NOTES.md as release history. Link durable behavior to OpenSpec after the baseline is applied and archived.
- Distinguish verified code behavior, synthetic test coverage, and live-browser verification still required. Do not treat historical requests or old documentation as implemented behavior.
## Capabilities
### New Capabilities
- `session-authentication`: Identity acquisition/reuse, SPA/SR session recovery, credential routing and profile gates.
- `opportunity-exploration`: Saved-search queries, pagination, fiscal Reference, coordinated filters, table sorting and indicators.
- `opportunity-calendar`: Reference-bounded monthly scrolling, daily cards, fiscal labels and current-week positioning.
- `product-workloads`: Product enrichment, workload caching, Booking classification, projections and forecast updates.
- `service-requests`: Progressive CRM SR enrichment, saved-search lookup, customer/internal SR details and creation.
- `workload-actions`: Ownership badges, dependent loading, action metadata and validated creation.
- `manage-time`: Weekly entry viewing/editing, task selection, validation and explicit writes.
- `customer-insights`: Installed Base, consumption plans/trends/details and Rate Card exploration.
- `organization-charts`: Local hierarchy storage, contact lookup/creation and opportunities by contact.
- `extension-shell`: Fusion entry points, bookmarks, keyboard shortcuts, modal interaction and printing.
- `preferences-cache`: Persistent preferences, sorting and cache invalidation boundaries.
- `request-debugging`: Request capture, lazy rendering, history lifetime and sensitive-value visibility.
### Modified Capabilities
None. The existing OpenSpec specification inventory is empty.
## Impact
- Planning artifacts are confined to `openspec/changes/document-project-baseline/`.
- Subsequent application updates README.md and docs/architecture.md; successful archive promotes the reviewed baseline to `openspec/specs/`.
- Source, generated distributions, API contracts, browser permissions, dependencies, storage formats and version 1.1.0 remain unchanged by this documentation effort.
- No release, tag, commit, live Oracle write, new authentication behavior, or OpenSpec schema/configuration customization is included.

View File

@@ -0,0 +1,51 @@
## Purpose
Provide customer-specific Installed Base and consumption exploration without disrupting the main opportunity view.
## ADDED Requirements
### Requirement: Installed Base uses the registry identifier
Installed Base SHALL first resolve account summary and query using its `registryId` with the SAIB authorization. It SHALL continue pagination until `hasMore=false`, and reject invalid or stalled pages instead of presenting a partial list as complete.
#### Scenario: More installed items exist
- **WHEN** an Installed Base page returns `hasMore=true`
- **THEN** subsequent pages are loaded before the complete result is presented
### Requirement: Installed Base avoids duplicate customer presentation
Installed Base SHALL show the customer in the modal header, omit the Customer column, and provide searchable sortable data with loading skeletons.
#### Scenario: User searches installed items
- **WHEN** search text is entered
- **THEN** loaded rows and the result count update locally without another account query
### Requirement: Consumption follows the selected plan
Customer consumption SHALL resolve available account plans and load trends for the selected plan with quarterly, monthly, weekly and daily periods. Plan detail loading SHALL be independent of trend loading and failed details SHALL not block the chart.
#### Scenario: User switches plans
- **WHEN** the selected plan changes while an earlier plan request is pending
- **THEN** late results from the earlier plan do not replace the selected plan's view
### Requirement: Projection details preserve safe order navigation
Consumption plan details SHALL expose Forfeit and Overage information with nested line details in interactive tooltips. Only HTTPS order links on `accountdashboard.oracle.com` SHALL be clickable.
#### Scenario: Unsafe order URL
- **WHEN** a returned order URL uses a different host or unsupported scheme
- **THEN** the detail remains readable without offering that URL as a clickable order link
### Requirement: Rate Card queries the active plan
Rate Card SHALL query using the selected plan's `subPlanNum` and OCICONS authorization, show plan/customer context and provide search, sorting and multi-selection of distinct Order Numbers. The current request SHALL use a 10000-record limit without implying unsupported cursor pagination.
#### Scenario: Order filter isolates one choice
- **WHEN** all order numbers are selected and the user clicks one order
- **THEN** only that order remains selected and search continues to combine with the order filter
#### Scenario: Rate Card closes while loading
- **WHEN** the drawer closes before its request completes
- **THEN** the late result does not reopen or overwrite a different active plan view
### Requirement: Customer tables sort and search by meaningful values
Customer table search SHALL match case-insensitively and accent-insensitively across displayed values. Sorting SHALL use numeric or date values where applicable, and restore each supported table's saved ordering.
#### Scenario: Numeric amount ordering
- **WHEN** the user sorts a customer table by a monetary column
- **THEN** rows are ordered by numeric amount rather than by the formatted text

View File

@@ -0,0 +1,47 @@
## Purpose
Provide predictable entry points, navigation, modal interaction and printing across the supported browser extension targets.
## ADDED Requirements
### Requirement: Supported Fusion pages expose the extension
The extension SHALL provide its entry tile on the configured Oracle Fusion hcmUI and fscmUI FuseWelcome pages. A bookmarked URL with the extension shortcut marker SHALL open the main modal without requiring a click on the Sales group.
#### Scenario: Fusion rewrites the URL
- **WHEN** the user follows the shortcut URL and Fusion subsequently replaces its query string
- **THEN** the captured shortcut intent still permits the extension to open once when ready
### Requirement: Favorites use an explicit folder choice
Ctrl+Shift+F within the extension SHALL open a searchable browser-bookmark folder picker. Saving SHALL use the chosen writable folder and extension shortcut URL rather than silently choosing an arbitrary location.
#### Scenario: User selects a bookmark folder
- **WHEN** the user confirms a writable folder and saves
- **THEN** the extension shortcut is saved in that folder
### Requirement: Bookmark discovery keeps Last update first
When the shortcut is not bookmarked, the header SHALL show a prominent shortcut hint for 20 seconds. Last update SHALL remain the first badge and other badges SHALL appear to its right.
#### Scenario: Favorite is saved while the hint is visible
- **WHEN** the shortcut is successfully added to bookmarks
- **THEN** the favorite hint is removed without waiting for the remaining timeout
### Requirement: Non-full-page modals can be dragged by their header
Non-full-page modals SHALL support dragging from noninteractive areas of their title bar, allowing up to 70 percent of their width and height beyond viewport edges. Header controls SHALL retain their own interactions and full-page views SHALL not become draggable.
#### Scenario: Drag exceeds viewport edge
- **WHEN** the user drags a movable modal far outside the viewport
- **THEN** movement is clamped so at least 30 percent of each modal dimension remains within the allowed bounds
### Requirement: Shared presentation remains consistent
Modal titles, spacing, loading skeletons and table presentation SHALL follow the extension's shared visual patterns in light and dark themes. User-facing labels SHALL use English, with service-provided names preserved.
#### Scenario: Customer detail is opened while loading
- **WHEN** the user opens a customer table before its data arrives
- **THEN** its title and loading skeleton use the shared modal styling
### Requirement: Print output reflects the current opportunity view
The print action beside Booking/Workload filters SHALL produce a landscape-oriented browser print layout containing active filter summaries, indicators and the opportunity list with page padding.
#### Scenario: All customers and owners are selected
- **WHEN** the user opens the print layout with unrestricted Customer and Owner selections
- **THEN** it labels them `All customers` and `All owners` instead of expanding every selected name

View File

@@ -0,0 +1,51 @@
## Purpose
Let authorized users review and maintain weekly time entries with validated hours and explicit, traceable persistence.
## ADDED Requirements
### Requirement: Manage Time requires profile and session readiness
Manage Time SHALL remain unavailable to `SALES_REP` users. For eligible users, its launcher SHALL remain disabled until SR session readiness, and data request entry points SHALL enforce the profile gate before loading time data.
#### Scenario: Restricted profile with a ready session
- **WHEN** SR session preparation succeeds for a user with `SALES_REP`
- **THEN** Manage Time remains hidden and opening it does not send its data requests
### Requirement: Week navigation preserves the user's work
Manage Time SHALL restore its selected reference date, navigate in seven-day increments and retain local drafts while moving between weeks within the modal. Late responses for a different week or closed modal SHALL not replace active values.
#### Scenario: Navigate away and back
- **WHEN** the user edits an unsaved week, navigates to another week and returns
- **THEN** the earlier week's local edits remain available
### Requirement: Entry selections are dependent and searchable
Service Request, Activity and Task Type selections SHALL support autocomplete. Activities SHALL correspond to the chosen SR, task types SHALL match Service or Non Service rows, and duplicate SR selection SHALL be rejected with row feedback.
#### Scenario: Duplicate SR
- **WHEN** a user selects an SR already used by another row
- **THEN** the previous selection is restored and the existing row is highlighted
### Requirement: Hours are validated before persistence
Each entered daily hour value SHALL be numeric and within the inclusive range 0 through 24. The view SHALL show daily totals and reject invalid rows before any affected write.
#### Scenario: Invalid daily hours
- **WHEN** a user attempts to save a row containing 25 hours for one day
- **THEN** validation prevents that invalid time entry from being submitted
### Requirement: Save controls creation updates and deletions
Save SHALL create new entries with POST, update changed persisted entries with PUT and apply queued persisted deletions with DELETE. Removing an unsaved row SHALL remain local. Writes SHALL use the required session and current-resource context.
#### Scenario: Persisted row is removed before Save
- **WHEN** a user removes a persisted row without saving
- **THEN** the deletion remains queued and no DELETE is sent yet
#### Scenario: Partial save failure
- **WHEN** some entry operations succeed and another operation fails
- **THEN** successful results remain reflected locally and unfinished work remains available for retry without duplicating confirmed writes
### Requirement: Loading and failures are local to the time view
Manage Time SHALL display skeleton rows while loading and provide retry on data failure without discarding the user's current draft values.
#### Scenario: Week retrieval fails
- **WHEN** the active week's request fails
- **THEN** the modal exposes the failure and retry while protecting existing edits

View File

@@ -0,0 +1,44 @@
## Purpose
Present the filtered opportunities by close date in a vertically navigable calendar bounded by the chosen Reference.
## ADDED Requirements
### Requirement: Calendar is a Reference-bounded monthly view
Calendar SHALL show a continuous monthly view restricted to the selected Reference, with vertical scrolling and no month/week switch or previous/next navigation buttons. Switching between Overview and Calendar SHALL not fetch opportunities again.
#### Scenario: Reference spans multiple months
- **WHEN** the user opens Calendar for a multi-month Reference
- **THEN** all months in the Reference remain reachable by vertical scrolling, including months with no opportunities
### Requirement: Every daily opportunity has a card
Each day SHALL show cards for all matching opportunities with a scrollable daily region when necessary. A hidden-event overflow link SHALL not replace the extra cards.
#### Scenario: Many opportunities close on one day
- **WHEN** the day's cards exceed its available height
- **THEN** the user can scroll within the day to reach all cards and the count reflects all matching opportunities
### Requirement: Initial positioning includes adjacent weeks
When today is within Reference, Calendar SHALL position around the current week and adjust visible week sizing to include the preceding and following weeks where the Reference permits. Dates outside Reference SHALL remain excluded.
#### Scenario: Current week crosses a month boundary
- **WHEN** Calendar positions around today and the three-week interval crosses into another month
- **THEN** the rendered portions of those weeks are included in the positioning calculation
### Requirement: Dates retain their calendar-day meaning
Calendar SHALL use `EffectiveDate` as a date-only close date without timezone shifts. Opportunities with missing or invalid close dates SHALL remain available in a separate list, and fiscal week labels SHALL use Sunday-start weeks and a June-start fiscal year.
#### Scenario: API date includes an offset
- **WHEN** an opportunity close date contains a timezone offset
- **THEN** its card retains the API calendar day instead of shifting to an adjacent day
#### Scenario: Missing close date
- **WHEN** a filtered opportunity has no valid close date
- **THEN** it is accessible through the undated-opportunities list
### Requirement: Calendar details are read-only
Opportunity cards SHALL show stage information and open read-only details with the corresponding opportunity link. Calendar labels and controls SHALL be in English.
#### Scenario: User selects a card
- **WHEN** an opportunity card is clicked
- **THEN** its details are shown without changing the opportunity in Oracle

View File

@@ -0,0 +1,63 @@
## Purpose
Let users explore Oracle opportunities within a fiscal Reference using consistent filters, totals and sortable tabular data.
## ADDED Requirements
### Requirement: Queries use the user's default saved search
Opportunity queries SHALL resolve the unique saved search marked `isDefault=true` and use its `savedSearchNumber` in `copiedFrom` for every page. Missing or ambiguous default searches SHALL prevent a dependent opportunity query.
#### Scenario: Valid default search
- **WHEN** saved searches return exactly one valid default
- **THEN** the opportunity payload uses `queries/<savedSearchNumber>` and reuses it throughout pagination
#### Scenario: No valid default
- **WHEN** no unique default saved search is returned
- **THEN** the extension reports the failure instead of using a hard-coded saved-search identifier
### Requirement: Opportunities display before background enrichment
The extension SHALL collect the opportunity result pages before displaying the ready list, then start Service Request and product enrichment without waiting for them to finish. Responses from an obsolete Reference request SHALL not replace the active result.
#### Scenario: SR remains pending
- **WHEN** opportunity pagination completes and Service Request loading remains pending
- **THEN** the opportunity table is already usable
#### Scenario: Reference changes during loading
- **WHEN** an older query completes after a newer Reference query has started
- **THEN** the older result does not overwrite the active view
### Requirement: Reference uses Oracle fiscal periods
The selected Reference SHALL determine the query date range using a June-through-May fiscal year. Opportunity type selections SHALL also constrain the query; an empty type selection SHALL show an explanatory empty state without sending an opportunity query.
#### Scenario: Fiscal first quarter
- **WHEN** Reference resolves to the first fiscal quarter
- **THEN** the selected period covers June through August with the next quarter boundary excluded
### Requirement: Filters are coordinated across views
Overview and Calendar SHALL share search, Customer, Owner, Status, Stage, eligible SR/Action filters and Booking/Workload filters. Customer options SHALL narrow by selected Owners and Owner options by selected Customers without additional API calls.
#### Scenario: Customer narrows owner choices
- **WHEN** a customer associated with two owners is selected
- **THEN** the Owner choices contain the corresponding owners and widening the customer selection restores applicable choices
#### Scenario: All-selected item is clicked
- **WHEN** the user clicks an item while all choices in its multiselect or filter-badge group are selected
- **THEN** only that item remains selected and subsequent clicks can add other choices
#### Scenario: Booking isolation
- **WHEN** both Booking and Workload are selected and Booking is clicked
- **THEN** only Booking opportunities remain visible in both views
### Requirement: Overview preserves table meaning and ordering
Overview SHALL restore its last sort column and direction, display customer `PartyName`, label win probability `Prob.`, and give Name and Customer greater width priority. Monetary cells and their headers SHALL align right; other headers SHALL align left.
#### Scenario: Overview reopens
- **WHEN** the user reopens Overview after selecting a descending Amount sort
- **THEN** that ordering is restored and the displayed amount values determine the order
### Requirement: Indicators reflect the filtered opportunities
The opportunity count and stage amounts SHALL follow the active filters without issuing additional queries solely to calculate the displayed indicators.
#### Scenario: Local filter changes
- **WHEN** the user narrows the loaded list using a Customer filter
- **THEN** the count and stage indicators update with the filtered opportunities

View File

@@ -0,0 +1,66 @@
## Purpose
Maintain customer organization charts locally while enriching people with Oracle contact information and related opportunities.
## ADDED Requirements
### Requirement: Charts are scoped and stored locally
Charts SHALL persist JSON-compatible records in extension-owned IndexedDB, scoped by user and customer in the browser profile. Records SHALL include revision and update metadata; stale saves SHALL be rejected. Email SHALL not persist in chart nodes.
#### Scenario: Concurrent chart change
- **WHEN** a user saves an outdated revision after another window has saved the chart
- **THEN** the save is rejected with guidance to reopen the current chart
#### Scenario: Browser profile is different
- **WHEN** the same user accesses the customer in another browser profile
- **THEN** this local chart is not automatically synchronized to that profile
### Requirement: Hierarchy editing preserves remaining people
The chart SHALL support adding people, changing their manager, notes, branch expansion/collapse, zoom and panning. It SHALL reject invalid hierarchies, support up to 500 people and 60 levels, and promote direct reports to the removed person's manager when a person is deleted.
#### Scenario: Manager is deleted
- **WHEN** a user deletes a person with direct reports
- **THEN** those reports remain in the chart under the deleted person's manager
#### Scenario: Unsaved chart is closed
- **WHEN** the user closes a chart with unsaved changes
- **THEN** the extension requests confirmation before discarding those changes
### Requirement: Contact autocomplete is debounced and customer-scoped
Name autocomplete SHALL search contacts after a 350 ms typing pause, filter by the customer's PartyId, display PartyName and JobTitle, and ignore superseded results. Empty queries and opening the chart alone SHALL not issue contact searches.
#### Scenario: User keeps typing
- **WHEN** another character arrives before the search delay expires
- **THEN** the pending search is superseded rather than sending one query per character
### Requirement: Person details refresh contact data on demand
Opening a stored person SHALL query contacts using the person's Name as keywords and match by contact PartyId. A matching result SHALL refresh Name and Job Title while preserving notes and reporting relationships; Email SHALL remain transient. Job Title SHALL be eligible for chart persistence.
#### Scenario: Contact data changed
- **WHEN** a matching contact response has a new name or job title
- **THEN** the working chart updates those fields and retains the person's notes and manager
### Requirement: Contact PartyNumber is preserved as an opaque identifier
Chart nodes SHALL retain the contact's PartyNumber, including alphanumeric values. Opening person details SHALL persist a returned PartyNumber; opportunity lookup SHALL use an existing stored value and attempt resolution when it is missing.
#### Scenario: Alphanumeric number is already saved
- **WHEN** opportunities are requested for a person with PartyNumber `A4KCS2P`
- **THEN** that exact value is used in `OpportunityContact.ContactPartyNumber`, without numeric conversion
### Requirement: Contact opportunity tables load all pages
The person card SHALL provide an opportunities action that opens `Opportunities | <name>`, shows a skeleton while loading and follows `hasMore` until all pages are collected. The resulting table SHALL be sortable and link each Name to its corresponding opportunity in a new tab.
#### Scenario: Contact has more than one result page
- **WHEN** the opportunities response sets `hasMore=true`
- **THEN** the next offset is queried using the same contact PartyNumber until the result is complete
### Requirement: New contacts are created explicitly
The contact-creation form SHALL require First Name, Last Name and Email, map email/phone consent to their corresponding Do/DoNot flags and obtain account identifiers from the selected customer. Provided phone components SHALL be combined as country code, hyphen and number. Save SHALL submit directly without a payload-preview step.
#### Scenario: Optional phone is omitted
- **WHEN** required contact fields are valid but a phone number is not filled
- **THEN** that absent phone value is not included as a populated contact field
#### Scenario: Phone components are entered
- **WHEN** country code `55` and phone number `99999999999` are provided
- **THEN** the submitted raw phone value is `55-99999999999`

View File

@@ -0,0 +1,44 @@
## Purpose
Preserve user preferences and reuse eligible data without allowing expired or invalidated results to silently replace fresh information.
## ADDED Requirements
### Requirement: Preferences survive reopening
The extension SHALL persist supported filter preferences, theme, opportunity type selections and cache duration in the browser profile. Invalid or unavailable stored values SHALL fall back to usable defaults or in-memory state.
#### Scenario: Stored preference cannot be read
- **WHEN** browser storage is unavailable or a saved preference is invalid
- **THEN** the extension remains usable with its fallback rather than failing to open
### Requirement: Sort preferences belong to individual tables
Supported sortable tables SHALL retain their own last sort key and direction without changing other tables' preferences. Product detail tables SHALL remain excluded from this sorting behavior.
#### Scenario: Two tables use different sorts
- **WHEN** the user sorts Installed Base by amount and contact opportunities by close date
- **THEN** reopening each table restores its own saved ordering
### Requirement: Cache reuse respects validity and identity boundaries
Product, workload and activity caches SHALL apply their configured lifetimes and existing identity/account/product-update scopes. A missing or expired activity cache SHALL not be represented as a confirmed empty result.
#### Scenario: Workload token rotates
- **WHEN** the same user's account workload data is still valid but the authorization token changes
- **THEN** the account data can be reused without treating the token string as a different customer identity
### Requirement: Only eligible reads are reused
Form request caching SHALL reuse only known read operations, share concurrent reads and invalidate applicable entries after successful writes. Create, update and delete operations SHALL not be served from a response cache.
#### Scenario: Repeated write
- **WHEN** the user explicitly submits a new valid write operation
- **THEN** the operation goes through the write transport rather than returning a cached success
### Requirement: Refresh all invalidates reusable data safely
Refresh all SHALL invalidate product/activity and related cached data before reloading the active Reference, preserve user preferences and authentication, and prevent pre-invalidation responses from repopulating invalidated cache entries. Cache invalidation errors SHALL be reported.
#### Scenario: Old product request finishes after refresh
- **WHEN** an old request completes after its cache has been invalidated
- **THEN** its result does not restore the invalidated cache snapshot
#### Scenario: Activity updates arrive
- **WHEN** only background activity lookup results change
- **THEN** those lookups do not independently advance the product Last update timestamp

View File

@@ -0,0 +1,55 @@
## Purpose
Enrich opportunities with products and workload information and support review and explicit updates of consumption projections.
## ADDED Requirements
### Requirement: Product enrichment runs in the background
Product loading SHALL update opportunity product counts and progress without blocking the displayed opportunity list. Product detail tables SHALL retain their source order rather than applying the general table sorting preference.
#### Scenario: Some products are still loading
- **WHEN** opportunities are visible and their products have not all arrived
- **THEN** completed product results update their related opportunities while remaining products continue loading
### Requirement: Workload retrieval is shared by customer
Workload retrieval SHALL consolidate all result pages for each distinct customer account, reuse eligible cached results and correlate workloads to products by revenue identifier. Concurrent consumers of the same account SHALL share loading.
#### Scenario: Multiple opportunities share a customer
- **WHEN** their products require workloads for the same account
- **THEN** one shared account retrieval supplies the matching products and continues while the response indicates more pages
### Requirement: Booking classification requires loaded products without Workload names
An opportunity with a nonempty loaded product list SHALL display a distinct Booking badge when every product has a blank or `-` Workload name. If any product has a Workload name, the existing ramp badge behavior SHALL remain.
#### Scenario: No products have an assigned Workload
- **WHEN** all loaded products have blank or `-` Workload names
- **THEN** Booking replaces Ramp Months and the opportunity matches the Booking filter
#### Scenario: Mixed product assignments
- **WHEN** at least one product has a Workload name
- **THEN** the opportunity retains its ramp behavior and is not classified as Booking
### Requirement: Projection access is independent of a difference alert
Clicking a ramp badge SHALL open consumption projection even when forecast and calculated amounts match. Difference alerts SHALL be placed between ramp and SR badges; product alerts without a workload record SHALL indicate unavailability with disabled styling.
#### Scenario: Forecast matches calculation
- **WHEN** the user clicks a ramp badge for a product with no forecast difference
- **THEN** Consumption projection still opens
### Requirement: Projection comparison uses calculated monthly values
Consumption projection SHALL calculate monthly amounts using adjusted ACR, ramp months and first-month proration, and compare forecast minus calculated amounts at cent precision.
#### Scenario: Monthly discrepancy
- **WHEN** a calculated monthly amount differs from its forecast after rounding to cents
- **THEN** the comparison shows the difference and enables the corresponding alert
### Requirement: Estimate updates require confirmation
Update estimates SHALL ask for confirmation before writing calculated values to SPA, prevent duplicate submissions and report success or failure. Successful updates SHALL refresh the comparison and invalidate affected workload cache data.
#### Scenario: Confirmation is cancelled
- **WHEN** the user declines the update confirmation
- **THEN** no forecast update is sent
#### Scenario: Confirmed update succeeds
- **WHEN** the user confirms and the update request succeeds
- **THEN** the comparison reflects the new values and subsequent workload reads do not reuse the invalidated values

View File

@@ -0,0 +1,51 @@
## Purpose
Make extension HTTP activity inspectable without pre-rendering an ever-growing request interface or confusing cached data with new network operations.
## ADDED Requirements
### Requirement: HTTP activity has a visible lifecycle
Debug Requests SHALL record request identity, method, URL, timing, headers, body, response and final status when available. Running requests SHALL transition to success or failure, cache hits SHALL not create fictitious HTTP requests, and entries SHALL be grouped by request category.
#### Scenario: Request is pending
- **WHEN** a logged request has started but not completed
- **THEN** it is displayed as running with its start time and later updates with completion or failure
### Requirement: Request history is persisted per tab context
Request records SHALL be stored in page localStorage under a tab-specific namespace. Closing the main modal SHALL preserve them. Starting page one of a new opportunity query SHALL reset that history; later pages of the same query SHALL retain it.
#### Scenario: Modal closes and reopens
- **WHEN** the main modal closes and reopens without a new opportunity query
- **THEN** the existing debug history remains available
#### Scenario: New query begins
- **WHEN** the first opportunity page is about to be sent
- **THEN** old history is cleared for that tab without deleting preferences or another tab's debug records
### Requirement: Debug rendering is deferred
The hidden debug view SHALL avoid constructing request-detail DOM. Opening the view SHALL render request summaries and expanding a request SHALL construct its details, preserving expansion and scroll state across updates where applicable.
#### Scenario: Requests accumulate while debug is closed
- **WHEN** requests complete while the debug panel is hidden
- **THEN** records are captured without mounting their detail elements in the hidden panel
### Requirement: Debug access stays above other extension views
Ctrl+Shift+D SHALL toggle Debug Requests as a full-page extension view above other extension modals. Closing it SHALL return to the underlying view without opening a browser tab or native fullscreen mode.
#### Scenario: Debug opens over person details
- **WHEN** the user presses Ctrl+Shift+D while a nested modal is open
- **THEN** Debug Requests appears in front of that modal
### Requirement: Sensitive-value masking is a display control
Debug SHALL hide sensitive values by default and support explicit reveal of retained originals. Documentation SHALL distinguish masking from storage protection: captured debug records can retain sensitive response values and masking SHALL not be described as encryption or guaranteed redaction at rest.
#### Scenario: Identity token is captured
- **WHEN** the identity response includes a token
- **THEN** normal debug display hides the token and the explicit visibility control can reveal its retained value
### Requirement: Debug failures do not interrupt application requests
Unavailable or full localStorage SHALL leave records available in memory with explanatory feedback. Debug timers and persistence errors SHALL not prevent the underlying network request from starting or completing.
#### Scenario: Storage quota is exhausted
- **WHEN** persisting a request record fails due to localStorage quota
- **THEN** the request outcome remains available in memory and the application request continues normally

View File

@@ -0,0 +1,55 @@
## Purpose
Expose service requests linked to opportunities and customers, with progressive status badges, detail views and explicit record creation.
## ADDED Requirements
### Requirement: CRM SR queries resolve their saved search
Before the first CRM Service Request page, the extension SHALL retrieve SR search resources using the general Token Relay Bearer and use the first item's valid `savedSearchNumber` as `queries/<savedSearchNumber>` in `copiedFrom`.
#### Scenario: Search resources have no identifier
- **WHEN** the first item has no valid saved-search number
- **THEN** the dependent CRM SR query fails explicitly instead of using a fixed fallback
### Requirement: SR enrichment is progressive and nonblocking
For eligible users, CRM SR pages SHALL load after opportunities are displayed and update their linked badges in the background. The query SHALL select pending-delivery team SRs and match opportunities through the returned opportunity number.
#### Scenario: First SR page arrives
- **WHEN** more SR pages remain after the first successful page
- **THEN** information received so far can update the visible opportunities without waiting for all pages
### Requirement: Failed SR loading remains distinguishable from no matches
The extension SHALL distinguish loading, failed and confirmed empty SR results. Pagination SHALL detect lack of progress, retry each failed page at most five total attempts, and stop work superseded by a newer query.
#### Scenario: SR lookup fails
- **WHEN** a lookup cannot establish whether an opportunity has matching SRs
- **THEN** its badge indicates an unknown or failed state rather than confirmed absence
#### Scenario: Successful SR snapshot is reused
- **WHEN** a subsequent cycle can reuse the completed SR result within the current page session
- **THEN** it does not repeat the completed query unless explicitly invalidated
### Requirement: Customer SR details use the resolved account identifier
Customer Service Requests SHALL resolve account summary first, use its `id` for the customer SR query and consolidate pages of open SRs. The dialog SHALL provide a loading skeleton, searchable sortable results, and guards against late results after closure.
#### Scenario: Account has distinct identifiers
- **WHEN** account summary returns different `id` and `registryId` values
- **THEN** customer SR retrieval uses `id`, not `registryId`
### Requirement: Internal SR recovery is bounded
Internal SR details SHALL report failed requests and use bounded, shared session recovery rather than continuously issuing requests or opening a visible recovery tab.
#### Scenario: Endpoint remains unavailable
- **WHEN** an Internal SR read continues to fail after its allowed recovery attempts
- **THEN** the view shows failure and does not keep sending an unbounded automatic request loop
### Requirement: SR creation validates before writing
Service Request creation SHALL require valid form inputs before submission, retain entered data on failure and prevent duplicate submissions while saving. Internal SR creation SHALL resolve parent SR and current resource information before linking the new record to its parent.
#### Scenario: Required fields are missing
- **WHEN** Save is clicked on an incomplete SR form
- **THEN** the missing fields are highlighted and no create request is sent
#### Scenario: Create request fails
- **WHEN** a submitted SR creation fails
- **THEN** the user receives error feedback and their form values remain available

View File

@@ -0,0 +1,54 @@
## Purpose
Establish authenticated Oracle access before dependent data operations while preserving credential boundaries and profile restrictions.
## ADDED Requirements
### Requirement: Valid identity precedes dependent data requests
The extension SHALL obtain a successful current-user response containing a valid user identifier, name, roles array and token before starting dependent opportunity or activity data requests. Concurrent identity consumers SHALL share acquisition, and invalid responses SHALL retry with backoff.
#### Scenario: HTML is returned with HTTP 200
- **WHEN** current-user acquisition receives a login document instead of the required JSON
- **THEN** dependent data requests remain blocked and identity acquisition retries
#### Scenario: Identity is already established
- **WHEN** the user changes Reference or reopens the main modal in the same page session
- **THEN** the extension reuses the successful identity without another current-user HTTP request
### Requirement: SPA recovery verifies the loaded session page
SPA session recovery SHALL open the configured bootstrap URL in an inactive auxiliary tab and verify page readiness before probing identity. Login forms, error documents and OAuth callbacks SHALL not count as a ready SPA page.
#### Scenario: Recovery navigation
- **WHEN** identity recovery requires a SPA session
- **THEN** navigation starts at `https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=whitespace`
- **AND** the extension preserves the originating browser context and closes its owned auxiliary tab when recovery finishes
#### Scenario: Rejected callback document
- **WHEN** recovery encounters an authorization-error callback
- **THEN** it restarts from the configured bootstrap URL instead of replaying that callback as a successful session
### Requirement: SR session preparation has independent progress
SR session preparation SHALL be able to run concurrently with SPA identity preparation, using the SeaaS external application URL. It SHALL expose completion or failure and allow a user retry after failure.
#### Scenario: SR preparation fails
- **WHEN** preparation through `https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/` fails or reaches its 70-second timeout
- **THEN** the extension offers retry rather than declaring the SR session ready
### Requirement: SALES_REP activity access is restricted
For a user whose roles include `SALES_REP`, the extension SHALL hide CRM SR and Workload Action badges and Manage Time access, and SHALL not issue their dependent activity or Manage Time data requests.
#### Scenario: Restricted user after successful authentication
- **WHEN** identity resolves with the `SALES_REP` role
- **THEN** opportunities remain available but CRM activity badges and Manage Time are unavailable
- **AND** attempting to open Manage Time does not load its data
### Requirement: Credentials are specific to each Oracle service
The extension SHALL preserve operation-specific credentials and cookie inclusion. It SHALL use the general Token Relay Bearer for Fusion queries and the appropriate SPA consumer authorization for each SPA service rather than substituting token families.
#### Scenario: Customer SR authorization
- **WHEN** customer service requests are loaded using SPA-ORANGE authorization
- **THEN** the raw authorization value is sent in `custsr-auth` without a Bearer prefix
#### Scenario: Token Relay does not finish
- **WHEN** a Token Relay request exceeds its request timeout
- **THEN** the attempt reports failure instead of remaining indefinitely pending in Debug Requests

View File

@@ -0,0 +1,40 @@
## Purpose
Identify workload actions owned by the signed-in user and allow eligible users to create actions for opportunity products.
## ADDED Requirements
### Requirement: Action enrichment respects prerequisite data
Workload Action enrichment SHALL use distinct product revenue identifiers after the product cycle and successful or reused CRM SR loading. Unavailable SR data SHALL leave action state unknown rather than treating it as an empty result.
#### Scenario: SR prerequisite fails
- **WHEN** CRM SR loading fails for a completed product cycle
- **THEN** action enrichment is blocked and its badges do not assert that no actions exist
### Requirement: Ownership determines the action badge
The action badge SHALL aggregate returned actions for the opportunity products and mark a match when an action owner equals the general Token Relay principal. Missing principal or failed lookups SHALL not count as confirmed nonmatches.
#### Scenario: Actions exist for other owners
- **WHEN** returned actions have no owner matching the known principal
- **THEN** the badge indicates no matching ownership while its tooltip retains the returned actions
### Requirement: Read attempts are bounded and partial coverage is disclosed
Each action lookup SHALL make at most five total attempts and stop on success or session supersession. The current lookup SHALL expose `hasNext` as additional unqueried actions rather than implying that all pages were loaded.
#### Scenario: More action pages exist
- **WHEN** a successful action response sets `hasNext=true`
- **THEN** the tooltip states that additional actions exist and pagination is not enabled
### Requirement: Action forms use validated lookup selections
The Action form SHALL load team members and lookup values with the workload credential, support searchable selections, and submit selected codes rather than arbitrary typed search text. Required inputs and the date range SHALL be validated before Save.
#### Scenario: Search text is not an option
- **WHEN** the user types a value without selecting a valid lookup result
- **THEN** the text is not submitted as a valid option value
### Requirement: Action creation is explicit and preserves failure state
Saving a valid action SHALL send current-user attribution, UTC dates and a notes array, lock duplicate submission while pending and show a result toast. A failed create SHALL retain the form without automatically retrying the POST.
#### Scenario: Create fails
- **WHEN** the server rejects an action creation
- **THEN** the form remains available with the entered data and no automatic duplicate POST is issued

View File

@@ -0,0 +1,34 @@
# Tasks
These are application-phase tasks for a documentation baseline. Creating these planning artifacts does not complete the documentation edits below. No task authorizes runtime feature changes, schema customization, a release, or live Oracle writes.
## 1. Reconcile the documented user experience
- [x] 1.1 Update README.md Opportunity Views to describe the current Calendar, coordinated Customer/Owner filters, filter isolation and persisted sorting; verify against `tests/opportunityCalendar.test.cjs`, `tests/dependentFilters.test.cjs`, `tests/allOpportunityFilters.test.cjs` and `tests/tableSorting.test.cjs` with the existing Node test runner.
- [x] 1.2 Update README.md product/workload, Booking and projection descriptions and remove active AI-summary descriptions; verify the relevant behavior against `tests/badgeIcons.test.cjs`, `tests/ramp-projection.test.cjs` and `tests/workload-forecast-update.test.cjs`, and confirm no active AI API/module claim remains in README.md.
- [x] 1.3 Update README.md Organization Charts with stored alphanumeric PartyNumber, transient email, on-demand contact refresh, creation and paginated contact opportunities; verify against `tests/orgChart.test.cjs` and `tests/accountContact.test.cjs` without sending real contact writes.
- [x] 1.4 Update README.md customer insights and shell coverage with Rate Card order filtering, printing, favorite shortcuts/hints and draggable modal headers; verify against the relevant source and `tests/ratecard.test.cjs`, `tests/bookmarkShortcut.test.cjs`, `tests/bookmarkHint.test.cjs` and `tests/dialogDrag.test.cjs`, recording any visual behavior that remains untested in an installed browser.
## 2. Reconcile authentication and asynchronous loading
- [x] 2.1 Update README.md authentication to use the current SPA bootstrap URL, identity reuse, independent SR session preparation and SALES_REP restrictions including Manage Time; verify against `tests/currentUser.test.cjs`, `tests/srSessionPreparation.test.cjs` and `tests/manageTime.test.cjs`.
- [x] 2.2 Correct README.md SR/Actions sequencing and saved-search contracts, including progressive SR badges and allowed activity cache-miss requests for cached products; verify against `tests/srRequestPriority.test.cjs`, `tests/crmServiceRequests.test.cjs`, `tests/activityProgress.test.cjs` and `tests/productActivities.test.cjs`.
- [x] 2.3 Revise docs/architecture.md runtime contexts, generated manifest permissions/hosts, service credentials, saved searches and timeout/stale-response behavior; verify each changed contract against `scripts/build.mjs`, composition roots and relevant service/auth sources, and run `tests/architecture.test.cjs` and `tests/buildOutput.test.cjs`.
## 3. Reconcile persistence and establish documentation navigation
- [x] 3.1 Update docs/architecture.md storage ownership and cache lifetimes for preferences, debug, identity/session state, products, workloads, activities and organization charts; verify each storage location/key against source and `tests/activityCache.test.cjs`, `tests/formRequestCache.test.cjs`, `tests/workloads.test.cjs` and `tests/refreshAll.test.cjs`.
- [x] 3.2 Correct README.md and docs/architecture.md debug descriptions to include lazy rendering, page-one reset, persistence across modal closure and the distinction between masking and stored originals; verify against `tests/debugStorage.test.cjs` and `tests/debugTabs.test.cjs` and remove blanket claims that all credentials remain only in memory.
- [x] 3.3 Add concise OpenSpec navigation to README.md and link the architecture documentation to the capability baseline, clearly identifying this change as proposed until archival; verify link targets, retain `# Opportunities Extension`, keep historical notes in RELEASE_NOTES.md and confirm no schema/config change is included.
## 4. Validate the integrated baseline
- [x] 4.1 Review all twelve capability specifications against the final documented behavior and evidence map; verify every requirement has a WHEN/THEN scenario, every proposed capability has one matching spec file, and no runtime enhancement is concealed as an existing contract. Record unresolved discrepancies rather than silently changing code.
- [x] 4.2 Run `openspec validate document-project-baseline --strict` and `node scripts/build.mjs --check`; verify strict validation succeeds and generated browser packages are unchanged and current.
- [x] 4.3 Run `node scripts/test.cjs` for final integration verification and `git diff --check`; record results and explicitly distinguish synthetic coverage from any unperformed installed-browser or live Oracle checks. Confirm the final change is confined to approved documentation and OpenSpec artifacts, with version and release notes unchanged.
## Workflow follow-up
- After application and review, use `$openspec-archive-change document-project-baseline` to promote the baseline specifications and archive the change. Archival is not a prerequisite for completing the tracked tasks.
- Confirm `openspec list --specs --json` lists the twelve promoted capabilities, no generated Purpose placeholder remains, and README navigation resolves to the durable specs.
- Use capability-specific delta changes for future behavior updates; do not rewrite the baseline from release history or silently replace it with an unreviewed implementation snapshot.

42
openspec/config.yaml Normal file
View File

@@ -0,0 +1,42 @@
schema: spec-driven
context: |
Opportunities Extension supports Oracle Fusion workflows in Chromium/Edge and Firefox.
Write OpenSpec artifacts and extension-authored UI text in English; preserve service-provided names.
Use openspec/specs/ for behavioral contracts, docs/architecture.md for execution and storage
boundaries, README.md for setup and usage, and RELEASE_NOTES.md for release history.
Preserve both browser targets and the existing extension design system unless the change
explicitly proposes a compatibility or presentation change.
When a release is requested, include a valid Firefox XPI; XPI packaging is Firefox-only.
Keep the README main title as Opportunities Extension and release history in RELEASE_NOTES.md.
OpenSpec archival and product release are separate operations.
rules:
proposal:
- Identify affected existing capabilities and distinguish observed behavior from proposed changes.
- State scope, non-goals and compatibility impact; do not introduce product requirements for tooling-only changes.
design:
- Reference the relevant architecture contracts instead of duplicating their inventories.
- For affected integrations, describe request ordering, credentials, pagination, retries and stale-response handling.
- For affected persistence, describe ownership, identity scope, invalidation and migration or rollback needs.
- For UI changes, identify shared components and relevant loading, empty, failure and theme behavior.
specs:
- Describe observable behavior with testable scenarios; keep implementation steps and module wiring in design and tasks.
- Use synthetic identifiers and examples; never embed real tokens, cookies or personal contact data.
tasks:
- Tie each task to a concrete deliverable and include verification proportional to the affected behavior.
- For source changes, include relevant synthetic tests, regeneration of affected browser outputs and a build consistency check.
- Record installed-browser and live Oracle checks separately from synthetic tests, including checks not performed.
- Include version, release notes and Firefox XPI work only when release delivery is part of the requested scope.
operations:
apply:
guidance:
- Edit source modules and regenerate distributions through the build script; do not hand-edit generated bundles.
- Use synthetic data for automated checks and avoid incidental writes to live Oracle services during validation.
- Run focused checks for the change; use node scripts/test.cjs for integration verification of runtime changes and node scripts/build.mjs --check for generated-output consistency.
- Report what changed, verification results and material limitations without claiming unperformed browser or live-service validation.
archive:
guidance:
- Check documentation links when moving a change and preserve the distinction between archived evidence and current specifications.
- Summarize task completion, specification synchronization and the final archive location; do not treat archival as a product release.

0
openspec/specs/.gitkeep Normal file
View File

View File

@@ -0,0 +1,53 @@
# customer-insights Specification
## Purpose
Provide customer-specific Installed Base and consumption exploration without disrupting the main opportunity view.
## Requirements
### Requirement: Installed Base uses the registry identifier
Installed Base SHALL first resolve account summary and query using its `registryId` with the SAIB authorization. It SHALL continue pagination until `hasMore=false`, and reject invalid or stalled pages instead of presenting a partial list as complete.
#### Scenario: More installed items exist
- **WHEN** an Installed Base page returns `hasMore=true`
- **THEN** subsequent pages are loaded before the complete result is presented
### Requirement: Installed Base avoids duplicate customer presentation
Installed Base SHALL show the customer in the modal header, omit the Customer column, and provide searchable sortable data with loading skeletons.
#### Scenario: User searches installed items
- **WHEN** search text is entered
- **THEN** loaded rows and the result count update locally without another account query
### Requirement: Consumption follows the selected plan
Customer consumption SHALL resolve available account plans and load trends for the selected plan with quarterly, monthly, weekly and daily periods. Plan detail loading SHALL be independent of trend loading and failed details SHALL not block the chart.
#### Scenario: User switches plans
- **WHEN** the selected plan changes while an earlier plan request is pending
- **THEN** late results from the earlier plan do not replace the selected plan's view
### Requirement: Projection details preserve safe order navigation
Consumption plan details SHALL expose Forfeit and Overage information with nested line details in interactive tooltips. Only HTTPS order links on `accountdashboard.oracle.com` SHALL be clickable.
#### Scenario: Unsafe order URL
- **WHEN** a returned order URL uses a different host or unsupported scheme
- **THEN** the detail remains readable without offering that URL as a clickable order link
### Requirement: Rate Card queries the active plan
Rate Card SHALL query using the selected plan's `subPlanNum` and OCICONS authorization, show plan/customer context and provide search, sorting and multi-selection of distinct Order Numbers. The current request SHALL use a 10000-record limit without implying unsupported cursor pagination.
#### Scenario: Order filter isolates one choice
- **WHEN** all order numbers are selected and the user clicks one order
- **THEN** only that order remains selected and search continues to combine with the order filter
#### Scenario: Rate Card closes while loading
- **WHEN** the drawer closes before its request completes
- **THEN** the late result does not reopen or overwrite a different active plan view
### Requirement: Customer tables sort and search by meaningful values
Customer table search SHALL match case-insensitively and accent-insensitively across displayed values. Sorting SHALL use numeric or date values where applicable, and restore each supported table's saved ordering.
#### Scenario: Numeric amount ordering
- **WHEN** the user sorts a customer table by a monetary column
- **THEN** rows are ordered by numeric amount rather than by the formatted text

View File

@@ -0,0 +1,49 @@
# extension-shell Specification
## Purpose
Provide predictable entry points, navigation, modal interaction and printing across the supported browser extension targets.
## Requirements
### Requirement: Supported Fusion pages expose the extension
The extension SHALL provide its entry tile on the configured Oracle Fusion hcmUI and fscmUI FuseWelcome pages. A bookmarked URL with the extension shortcut marker SHALL open the main modal without requiring a click on the Sales group.
#### Scenario: Fusion rewrites the URL
- **WHEN** the user follows the shortcut URL and Fusion subsequently replaces its query string
- **THEN** the captured shortcut intent still permits the extension to open once when ready
### Requirement: Favorites use an explicit folder choice
Ctrl+Shift+F within the extension SHALL open a searchable browser-bookmark folder picker. Saving SHALL use the chosen writable folder and extension shortcut URL rather than silently choosing an arbitrary location.
#### Scenario: User selects a bookmark folder
- **WHEN** the user confirms a writable folder and saves
- **THEN** the extension shortcut is saved in that folder
### Requirement: Bookmark discovery keeps Last update first
When the shortcut is not bookmarked, the header SHALL show a prominent shortcut hint for 20 seconds. Last update SHALL remain the first badge and other badges SHALL appear to its right.
#### Scenario: Favorite is saved while the hint is visible
- **WHEN** the shortcut is successfully added to bookmarks
- **THEN** the favorite hint is removed without waiting for the remaining timeout
### Requirement: Non-full-page modals can be dragged by their header
Non-full-page modals SHALL support dragging from noninteractive areas of their title bar, allowing up to 70 percent of their width and height beyond viewport edges. Header controls SHALL retain their own interactions and full-page views SHALL not become draggable.
#### Scenario: Drag exceeds viewport edge
- **WHEN** the user drags a movable modal far outside the viewport
- **THEN** movement is clamped so at least 30 percent of each modal dimension remains within the allowed bounds
### Requirement: Shared presentation remains consistent
Modal titles, spacing, loading skeletons and table presentation SHALL follow the extension's shared visual patterns in light and dark themes. User-facing labels SHALL use English, with service-provided names preserved.
#### Scenario: Customer detail is opened while loading
- **WHEN** the user opens a customer table before its data arrives
- **THEN** its title and loading skeleton use the shared modal styling
### Requirement: Print output reflects the current opportunity view
The print action beside Booking/Workload filters SHALL produce a landscape-oriented browser print layout containing active filter summaries, indicators and the opportunity list with page padding.
#### Scenario: All customers and owners are selected
- **WHEN** the user opens the print layout with unrestricted Customer and Owner selections
- **THEN** it labels them `All customers` and `All owners` instead of expanding every selected name

View File

@@ -0,0 +1,53 @@
# manage-time Specification
## Purpose
Let authorized users review and maintain weekly time entries with validated hours and explicit, traceable persistence.
## Requirements
### Requirement: Manage Time requires profile and session readiness
Manage Time SHALL remain unavailable to `SALES_REP` users. For eligible users, its launcher SHALL remain disabled until SR session readiness, and data request entry points SHALL enforce the profile gate before loading time data.
#### Scenario: Restricted profile with a ready session
- **WHEN** SR session preparation succeeds for a user with `SALES_REP`
- **THEN** Manage Time remains hidden and opening it does not send its data requests
### Requirement: Week navigation preserves the user's work
Manage Time SHALL restore its selected reference date, navigate in seven-day increments and retain local drafts while moving between weeks within the modal. Late responses for a different week or closed modal SHALL not replace active values.
#### Scenario: Navigate away and back
- **WHEN** the user edits an unsaved week, navigates to another week and returns
- **THEN** the earlier week's local edits remain available
### Requirement: Entry selections are dependent and searchable
Service Request, Activity and Task Type selections SHALL support autocomplete. Activities SHALL correspond to the chosen SR, task types SHALL match Service or Non Service rows, and duplicate SR selection SHALL be rejected with row feedback.
#### Scenario: Duplicate SR
- **WHEN** a user selects an SR already used by another row
- **THEN** the previous selection is restored and the existing row is highlighted
### Requirement: Hours are validated before persistence
Each entered daily hour value SHALL be numeric and within the inclusive range 0 through 24. The view SHALL show daily totals and reject invalid rows before any affected write.
#### Scenario: Invalid daily hours
- **WHEN** a user attempts to save a row containing 25 hours for one day
- **THEN** validation prevents that invalid time entry from being submitted
### Requirement: Save controls creation updates and deletions
Save SHALL create new entries with POST, update changed persisted entries with PUT and apply queued persisted deletions with DELETE. Removing an unsaved row SHALL remain local. Writes SHALL use the required session and current-resource context.
#### Scenario: Persisted row is removed before Save
- **WHEN** a user removes a persisted row without saving
- **THEN** the deletion remains queued and no DELETE is sent yet
#### Scenario: Partial save failure
- **WHEN** some entry operations succeed and another operation fails
- **THEN** successful results remain reflected locally and unfinished work remains available for retry without duplicating confirmed writes
### Requirement: Loading and failures are local to the time view
Manage Time SHALL display skeleton rows while loading and provide retry on data failure without discarding the user's current draft values.
#### Scenario: Week retrieval fails
- **WHEN** the active week's request fails
- **THEN** the modal exposes the failure and retry while protecting existing edits

View File

@@ -0,0 +1,46 @@
# opportunity-calendar Specification
## Purpose
Present the filtered opportunities by close date in a vertically navigable calendar bounded by the chosen Reference.
## Requirements
### Requirement: Calendar is a Reference-bounded monthly view
Calendar SHALL show a continuous monthly view restricted to the selected Reference, with vertical scrolling and no month/week switch or previous/next navigation buttons. Switching between Overview and Calendar SHALL not fetch opportunities again.
#### Scenario: Reference spans multiple months
- **WHEN** the user opens Calendar for a multi-month Reference
- **THEN** all months in the Reference remain reachable by vertical scrolling, including months with no opportunities
### Requirement: Every daily opportunity has a card
Each day SHALL show cards for all matching opportunities with a scrollable daily region when necessary. A hidden-event overflow link SHALL not replace the extra cards.
#### Scenario: Many opportunities close on one day
- **WHEN** the day's cards exceed its available height
- **THEN** the user can scroll within the day to reach all cards and the count reflects all matching opportunities
### Requirement: Initial positioning includes adjacent weeks
When today is within Reference, Calendar SHALL position around the current week and adjust visible week sizing to include the preceding and following weeks where the Reference permits. Dates outside Reference SHALL remain excluded.
#### Scenario: Current week crosses a month boundary
- **WHEN** Calendar positions around today and the three-week interval crosses into another month
- **THEN** the rendered portions of those weeks are included in the positioning calculation
### Requirement: Dates retain their calendar-day meaning
Calendar SHALL use `EffectiveDate` as a date-only close date without timezone shifts. Opportunities with missing or invalid close dates SHALL remain available in a separate list, and fiscal week labels SHALL use Sunday-start weeks and a June-start fiscal year.
#### Scenario: API date includes an offset
- **WHEN** an opportunity close date contains a timezone offset
- **THEN** its card retains the API calendar day instead of shifting to an adjacent day
#### Scenario: Missing close date
- **WHEN** a filtered opportunity has no valid close date
- **THEN** it is accessible through the undated-opportunities list
### Requirement: Calendar details are read-only
Opportunity cards SHALL show stage information and open read-only details with the corresponding opportunity link. Calendar labels and controls SHALL be in English.
#### Scenario: User selects a card
- **WHEN** an opportunity card is clicked
- **THEN** its details are shown without changing the opportunity in Oracle

View File

@@ -0,0 +1,65 @@
# opportunity-exploration Specification
## Purpose
Let users explore Oracle opportunities within a fiscal Reference using consistent filters, totals and sortable tabular data.
## Requirements
### Requirement: Queries use the user's default saved search
Opportunity queries SHALL resolve the unique saved search marked `isDefault=true` and use its `savedSearchNumber` in `copiedFrom` for every page. Missing or ambiguous default searches SHALL prevent a dependent opportunity query.
#### Scenario: Valid default search
- **WHEN** saved searches return exactly one valid default
- **THEN** the opportunity payload uses `queries/<savedSearchNumber>` and reuses it throughout pagination
#### Scenario: No valid default
- **WHEN** no unique default saved search is returned
- **THEN** the extension reports the failure instead of using a hard-coded saved-search identifier
### Requirement: Opportunities display before background enrichment
The extension SHALL collect the opportunity result pages before displaying the ready list, then start Service Request and product enrichment without waiting for them to finish. Responses from an obsolete Reference request SHALL not replace the active result.
#### Scenario: SR remains pending
- **WHEN** opportunity pagination completes and Service Request loading remains pending
- **THEN** the opportunity table is already usable
#### Scenario: Reference changes during loading
- **WHEN** an older query completes after a newer Reference query has started
- **THEN** the older result does not overwrite the active view
### Requirement: Reference uses Oracle fiscal periods
The selected Reference SHALL determine the query date range using a June-through-May fiscal year. Opportunity type selections SHALL also constrain the query; an empty type selection SHALL show an explanatory empty state without sending an opportunity query.
#### Scenario: Fiscal first quarter
- **WHEN** Reference resolves to the first fiscal quarter
- **THEN** the selected period covers June through August with the next quarter boundary excluded
### Requirement: Filters are coordinated across views
Overview and Calendar SHALL share search, Customer, Owner, Status, Stage, eligible SR/Action filters and Booking/Workload filters. Customer options SHALL narrow by selected Owners and Owner options by selected Customers without additional API calls.
#### Scenario: Customer narrows owner choices
- **WHEN** a customer associated with two owners is selected
- **THEN** the Owner choices contain the corresponding owners and widening the customer selection restores applicable choices
#### Scenario: All-selected item is clicked
- **WHEN** the user clicks an item while all choices in its multiselect or filter-badge group are selected
- **THEN** only that item remains selected and subsequent clicks can add other choices
#### Scenario: Booking isolation
- **WHEN** both Booking and Workload are selected and Booking is clicked
- **THEN** only Booking opportunities remain visible in both views
### Requirement: Overview preserves table meaning and ordering
Overview SHALL restore its last sort column and direction, display customer `PartyName`, label win probability `Prob.`, and give Name and Customer greater width priority. Monetary cells and their headers SHALL align right; other headers SHALL align left.
#### Scenario: Overview reopens
- **WHEN** the user reopens Overview after selecting a descending Amount sort
- **THEN** that ordering is restored and the displayed amount values determine the order
### Requirement: Indicators reflect the filtered opportunities
The opportunity count and stage amounts SHALL follow the active filters without issuing additional queries solely to calculate the displayed indicators.
#### Scenario: Local filter changes
- **WHEN** the user narrows the loaded list using a Customer filter
- **THEN** the count and stage indicators update with the filtered opportunities

View File

@@ -0,0 +1,68 @@
# organization-charts Specification
## Purpose
Maintain customer organization charts locally while enriching people with Oracle contact information and related opportunities.
## Requirements
### Requirement: Charts are scoped and stored locally
Charts SHALL persist JSON-compatible records in extension-owned IndexedDB, scoped by user and customer in the browser profile. Records SHALL include revision and update metadata; stale saves SHALL be rejected. Email SHALL not persist in chart nodes.
#### Scenario: Concurrent chart change
- **WHEN** a user saves an outdated revision after another window has saved the chart
- **THEN** the save is rejected with guidance to reopen the current chart
#### Scenario: Browser profile is different
- **WHEN** the same user accesses the customer in another browser profile
- **THEN** this local chart is not automatically synchronized to that profile
### Requirement: Hierarchy editing preserves remaining people
The chart SHALL support adding people, changing their manager, notes, branch expansion/collapse, zoom and panning. It SHALL reject invalid hierarchies, support up to 500 people and 60 levels, and promote direct reports to the removed person's manager when a person is deleted.
#### Scenario: Manager is deleted
- **WHEN** a user deletes a person with direct reports
- **THEN** those reports remain in the chart under the deleted person's manager
#### Scenario: Unsaved chart is closed
- **WHEN** the user closes a chart with unsaved changes
- **THEN** the extension requests confirmation before discarding those changes
### Requirement: Contact autocomplete is debounced and customer-scoped
Name autocomplete SHALL search contacts after a 350 ms typing pause, filter by the customer's PartyId, display PartyName and JobTitle, and ignore superseded results. Empty queries and opening the chart alone SHALL not issue contact searches.
#### Scenario: User keeps typing
- **WHEN** another character arrives before the search delay expires
- **THEN** the pending search is superseded rather than sending one query per character
### Requirement: Person details refresh contact data on demand
Opening a stored person SHALL query contacts using the person's Name as keywords and match by contact PartyId. A matching result SHALL refresh Name and Job Title while preserving notes and reporting relationships; Email SHALL remain transient. Job Title SHALL be eligible for chart persistence.
#### Scenario: Contact data changed
- **WHEN** a matching contact response has a new name or job title
- **THEN** the working chart updates those fields and retains the person's notes and manager
### Requirement: Contact PartyNumber is preserved as an opaque identifier
Chart nodes SHALL retain the contact's PartyNumber, including alphanumeric values. Opening person details SHALL persist a returned PartyNumber; opportunity lookup SHALL use an existing stored value and attempt resolution when it is missing.
#### Scenario: Alphanumeric number is already saved
- **WHEN** opportunities are requested for a person with PartyNumber `A4KCS2P`
- **THEN** that exact value is used in `OpportunityContact.ContactPartyNumber`, without numeric conversion
### Requirement: Contact opportunity tables load all pages
The person card SHALL provide an opportunities action that opens `Opportunities | <name>`, shows a skeleton while loading and follows `hasMore` until all pages are collected. The resulting table SHALL be sortable and link each Name to its corresponding opportunity in a new tab.
#### Scenario: Contact has more than one result page
- **WHEN** the opportunities response sets `hasMore=true`
- **THEN** the next offset is queried using the same contact PartyNumber until the result is complete
### Requirement: New contacts are created explicitly
The contact-creation form SHALL require First Name, Last Name and Email, map email/phone consent to their corresponding Do/DoNot flags and obtain account identifiers from the selected customer. Provided phone components SHALL be combined as country code, hyphen and number. Save SHALL submit directly without a payload-preview step.
#### Scenario: Optional phone is omitted
- **WHEN** required contact fields are valid but a phone number is not filled
- **THEN** that absent phone value is not included as a populated contact field
#### Scenario: Phone components are entered
- **WHEN** country code `55` and phone number `99999999999` are provided
- **THEN** the submitted raw phone value is `55-99999999999`

View File

@@ -0,0 +1,46 @@
# preferences-cache Specification
## Purpose
Preserve user preferences and reuse eligible data without allowing expired or invalidated results to silently replace fresh information.
## Requirements
### Requirement: Preferences survive reopening
The extension SHALL persist supported filter preferences, theme, opportunity type selections and cache duration in the browser profile. Invalid or unavailable stored values SHALL fall back to usable defaults or in-memory state.
#### Scenario: Stored preference cannot be read
- **WHEN** browser storage is unavailable or a saved preference is invalid
- **THEN** the extension remains usable with its fallback rather than failing to open
### Requirement: Sort preferences belong to individual tables
Supported sortable tables SHALL retain their own last sort key and direction without changing other tables' preferences. Product detail tables SHALL remain excluded from this sorting behavior.
#### Scenario: Two tables use different sorts
- **WHEN** the user sorts Installed Base by amount and contact opportunities by close date
- **THEN** reopening each table restores its own saved ordering
### Requirement: Cache reuse respects validity and identity boundaries
Product, workload and activity caches SHALL apply their configured lifetimes and existing identity/account/product-update scopes. A missing or expired activity cache SHALL not be represented as a confirmed empty result.
#### Scenario: Workload token rotates
- **WHEN** the same user's account workload data is still valid but the authorization token changes
- **THEN** the account data can be reused without treating the token string as a different customer identity
### Requirement: Only eligible reads are reused
Form request caching SHALL reuse only known read operations, share concurrent reads and invalidate applicable entries after successful writes. Create, update and delete operations SHALL not be served from a response cache.
#### Scenario: Repeated write
- **WHEN** the user explicitly submits a new valid write operation
- **THEN** the operation goes through the write transport rather than returning a cached success
### Requirement: Refresh all invalidates reusable data safely
Refresh all SHALL invalidate product/activity and related cached data before reloading the active Reference, preserve user preferences and authentication, and prevent pre-invalidation responses from repopulating invalidated cache entries. Cache invalidation errors SHALL be reported.
#### Scenario: Old product request finishes after refresh
- **WHEN** an old request completes after its cache has been invalidated
- **THEN** its result does not restore the invalidated cache snapshot
#### Scenario: Activity updates arrive
- **WHEN** only background activity lookup results change
- **THEN** those lookups do not independently advance the product Last update timestamp

View File

@@ -0,0 +1,57 @@
# product-workloads Specification
## Purpose
Enrich opportunities with products and workload information and support review and explicit updates of consumption projections.
## Requirements
### Requirement: Product enrichment runs in the background
Product loading SHALL update opportunity product counts and progress without blocking the displayed opportunity list. Product detail tables SHALL retain their source order rather than applying the general table sorting preference.
#### Scenario: Some products are still loading
- **WHEN** opportunities are visible and their products have not all arrived
- **THEN** completed product results update their related opportunities while remaining products continue loading
### Requirement: Workload retrieval is shared by customer
Workload retrieval SHALL consolidate all result pages for each distinct customer account, reuse eligible cached results and correlate workloads to products by revenue identifier. Concurrent consumers of the same account SHALL share loading.
#### Scenario: Multiple opportunities share a customer
- **WHEN** their products require workloads for the same account
- **THEN** one shared account retrieval supplies the matching products and continues while the response indicates more pages
### Requirement: Booking classification requires loaded products without Workload names
An opportunity with a nonempty loaded product list SHALL display a distinct Booking badge when every product has a blank or `-` Workload name. If any product has a Workload name, the existing ramp badge behavior SHALL remain.
#### Scenario: No products have an assigned Workload
- **WHEN** all loaded products have blank or `-` Workload names
- **THEN** Booking replaces Ramp Months and the opportunity matches the Booking filter
#### Scenario: Mixed product assignments
- **WHEN** at least one product has a Workload name
- **THEN** the opportunity retains its ramp behavior and is not classified as Booking
### Requirement: Projection access is independent of a difference alert
Clicking a ramp badge SHALL open consumption projection even when forecast and calculated amounts match. Difference alerts SHALL be placed between ramp and SR badges; product alerts without a workload record SHALL indicate unavailability with disabled styling.
#### Scenario: Forecast matches calculation
- **WHEN** the user clicks a ramp badge for a product with no forecast difference
- **THEN** Consumption projection still opens
### Requirement: Projection comparison uses calculated monthly values
Consumption projection SHALL calculate monthly amounts using adjusted ACR, ramp months and first-month proration, and compare forecast minus calculated amounts at cent precision.
#### Scenario: Monthly discrepancy
- **WHEN** a calculated monthly amount differs from its forecast after rounding to cents
- **THEN** the comparison shows the difference and enables the corresponding alert
### Requirement: Estimate updates require confirmation
Update estimates SHALL ask for confirmation before writing calculated values to SPA, prevent duplicate submissions and report success or failure. Successful updates SHALL refresh the comparison and invalidate affected workload cache data.
#### Scenario: Confirmation is cancelled
- **WHEN** the user declines the update confirmation
- **THEN** no forecast update is sent
#### Scenario: Confirmed update succeeds
- **WHEN** the user confirms and the update request succeeds
- **THEN** the comparison reflects the new values and subsequent workload reads do not reuse the invalidated values

View File

@@ -0,0 +1,53 @@
# request-debugging Specification
## Purpose
Make extension HTTP activity inspectable without pre-rendering an ever-growing request interface or confusing cached data with new network operations.
## Requirements
### Requirement: HTTP activity has a visible lifecycle
Debug Requests SHALL record request identity, method, URL, timing, headers, body, response and final status when available. Running requests SHALL transition to success or failure, cache hits SHALL not create fictitious HTTP requests, and entries SHALL be grouped by request category.
#### Scenario: Request is pending
- **WHEN** a logged request has started but not completed
- **THEN** it is displayed as running with its start time and later updates with completion or failure
### Requirement: Request history is persisted per tab context
Request records SHALL be stored in page localStorage under a tab-specific namespace. Closing the main modal SHALL preserve them. Starting page one of a new opportunity query SHALL reset that history; later pages of the same query SHALL retain it.
#### Scenario: Modal closes and reopens
- **WHEN** the main modal closes and reopens without a new opportunity query
- **THEN** the existing debug history remains available
#### Scenario: New query begins
- **WHEN** the first opportunity page is about to be sent
- **THEN** old history is cleared for that tab without deleting preferences or another tab's debug records
### Requirement: Debug rendering is deferred
The hidden debug view SHALL avoid constructing request-detail DOM. Opening the view SHALL render request summaries and expanding a request SHALL construct its details, preserving expansion and scroll state across updates where applicable.
#### Scenario: Requests accumulate while debug is closed
- **WHEN** requests complete while the debug panel is hidden
- **THEN** records are captured without mounting their detail elements in the hidden panel
### Requirement: Debug access stays above other extension views
Ctrl+Shift+D SHALL toggle Debug Requests as a full-page extension view above other extension modals. Closing it SHALL return to the underlying view without opening a browser tab or native fullscreen mode.
#### Scenario: Debug opens over person details
- **WHEN** the user presses Ctrl+Shift+D while a nested modal is open
- **THEN** Debug Requests appears in front of that modal
### Requirement: Sensitive-value masking is a display control
Debug SHALL hide sensitive values by default and support explicit reveal of retained originals. Documentation SHALL distinguish masking from storage protection: captured debug records can retain sensitive response values and masking SHALL not be described as encryption or guaranteed redaction at rest.
#### Scenario: Identity token is captured
- **WHEN** the identity response includes a token
- **THEN** normal debug display hides the token and the explicit visibility control can reveal its retained value
### Requirement: Debug failures do not interrupt application requests
Unavailable or full localStorage SHALL leave records available in memory with explanatory feedback. Debug timers and persistence errors SHALL not prevent the underlying network request from starting or completing.
#### Scenario: Storage quota is exhausted
- **WHEN** persisting a request record fails due to localStorage quota
- **THEN** the request outcome remains available in memory and the application request continues normally

View File

@@ -0,0 +1,57 @@
# service-requests Specification
## Purpose
Expose service requests linked to opportunities and customers, with progressive status badges, detail views and explicit record creation.
## Requirements
### Requirement: CRM SR queries resolve their saved search
Before the first CRM Service Request page, the extension SHALL retrieve SR search resources using the general Token Relay Bearer and use the first item's valid `savedSearchNumber` as `queries/<savedSearchNumber>` in `copiedFrom`.
#### Scenario: Search resources have no identifier
- **WHEN** the first item has no valid saved-search number
- **THEN** the dependent CRM SR query fails explicitly instead of using a fixed fallback
### Requirement: SR enrichment is progressive and nonblocking
For eligible users, CRM SR pages SHALL load after opportunities are displayed and update their linked badges in the background. The query SHALL select pending-delivery team SRs and match opportunities through the returned opportunity number.
#### Scenario: First SR page arrives
- **WHEN** more SR pages remain after the first successful page
- **THEN** information received so far can update the visible opportunities without waiting for all pages
### Requirement: Failed SR loading remains distinguishable from no matches
The extension SHALL distinguish loading, failed and confirmed empty SR results. Pagination SHALL detect lack of progress, retry each failed page at most five total attempts, and stop work superseded by a newer query.
#### Scenario: SR lookup fails
- **WHEN** a lookup cannot establish whether an opportunity has matching SRs
- **THEN** its badge indicates an unknown or failed state rather than confirmed absence
#### Scenario: Successful SR snapshot is reused
- **WHEN** a subsequent cycle can reuse the completed SR result within the current page session
- **THEN** it does not repeat the completed query unless explicitly invalidated
### Requirement: Customer SR details use the resolved account identifier
Customer Service Requests SHALL resolve account summary first, use its `id` for the customer SR query and consolidate pages of open SRs. The dialog SHALL provide a loading skeleton, searchable sortable results, and guards against late results after closure.
#### Scenario: Account has distinct identifiers
- **WHEN** account summary returns different `id` and `registryId` values
- **THEN** customer SR retrieval uses `id`, not `registryId`
### Requirement: Internal SR recovery is bounded
Internal SR details SHALL report failed requests and use bounded, shared session recovery rather than continuously issuing requests or opening a visible recovery tab.
#### Scenario: Endpoint remains unavailable
- **WHEN** an Internal SR read continues to fail after its allowed recovery attempts
- **THEN** the view shows failure and does not keep sending an unbounded automatic request loop
### Requirement: SR creation validates before writing
Service Request creation SHALL require valid form inputs before submission, retain entered data on failure and prevent duplicate submissions while saving. Internal SR creation SHALL resolve parent SR and current resource information before linking the new record to its parent.
#### Scenario: Required fields are missing
- **WHEN** Save is clicked on an incomplete SR form
- **THEN** the missing fields are highlighted and no create request is sent
#### Scenario: Create request fails
- **WHEN** a submitted SR creation fails
- **THEN** the user receives error feedback and their form values remain available

View File

@@ -0,0 +1,56 @@
# session-authentication Specification
## Purpose
Establish authenticated Oracle access before dependent data operations while preserving credential boundaries and profile restrictions.
## Requirements
### Requirement: Valid identity precedes dependent data requests
The extension SHALL obtain a successful current-user response containing a valid user identifier, name, roles array and token before starting dependent opportunity or activity data requests. Concurrent identity consumers SHALL share acquisition, and invalid responses SHALL retry with backoff.
#### Scenario: HTML is returned with HTTP 200
- **WHEN** current-user acquisition receives a login document instead of the required JSON
- **THEN** dependent data requests remain blocked and identity acquisition retries
#### Scenario: Identity is already established
- **WHEN** the user changes Reference or reopens the main modal in the same page session
- **THEN** the extension reuses the successful identity without another current-user HTTP request
### Requirement: SPA recovery verifies the loaded session page
SPA session recovery SHALL open the configured bootstrap URL in an inactive auxiliary tab and verify page readiness before probing identity. Login forms, error documents and OAuth callbacks SHALL not count as a ready SPA page.
#### Scenario: Recovery navigation
- **WHEN** identity recovery requires a SPA session
- **THEN** navigation starts at `https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=whitespace`
- **AND** the extension preserves the originating browser context and closes its owned auxiliary tab when recovery finishes
#### Scenario: Rejected callback document
- **WHEN** recovery encounters an authorization-error callback
- **THEN** it restarts from the configured bootstrap URL instead of replaying that callback as a successful session
### Requirement: SR session preparation has independent progress
SR session preparation SHALL be able to run concurrently with SPA identity preparation, using the SeaaS external application URL. It SHALL expose completion or failure and allow a user retry after failure.
#### Scenario: SR preparation fails
- **WHEN** preparation through `https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/` fails or reaches its 70-second timeout
- **THEN** the extension offers retry rather than declaring the SR session ready
### Requirement: SALES_REP activity access is restricted
For a user whose roles include `SALES_REP`, the extension SHALL hide CRM SR and Workload Action badges and Manage Time access, and SHALL not issue their dependent activity or Manage Time data requests.
#### Scenario: Restricted user after successful authentication
- **WHEN** identity resolves with the `SALES_REP` role
- **THEN** opportunities remain available but CRM activity badges and Manage Time are unavailable
- **AND** attempting to open Manage Time does not load its data
### Requirement: Credentials are specific to each Oracle service
The extension SHALL preserve operation-specific credentials and cookie inclusion. It SHALL use the general Token Relay Bearer for Fusion queries and the appropriate SPA consumer authorization for each SPA service rather than substituting token families.
#### Scenario: Customer SR authorization
- **WHEN** customer service requests are loaded using SPA-ORANGE authorization
- **THEN** the raw authorization value is sent in `custsr-auth` without a Bearer prefix
#### Scenario: Token Relay does not finish
- **WHEN** a Token Relay request exceeds its request timeout
- **THEN** the attempt reports failure instead of remaining indefinitely pending in Debug Requests

View File

@@ -0,0 +1,42 @@
# workload-actions Specification
## Purpose
Identify workload actions owned by the signed-in user and allow eligible users to create actions for opportunity products.
## Requirements
### Requirement: Action enrichment respects prerequisite data
Workload Action enrichment SHALL use distinct product revenue identifiers after the product cycle and successful or reused CRM SR loading. Unavailable SR data SHALL leave action state unknown rather than treating it as an empty result.
#### Scenario: SR prerequisite fails
- **WHEN** CRM SR loading fails for a completed product cycle
- **THEN** action enrichment is blocked and its badges do not assert that no actions exist
### Requirement: Ownership determines the action badge
The action badge SHALL aggregate returned actions for the opportunity products and mark a match when an action owner equals the general Token Relay principal. Missing principal or failed lookups SHALL not count as confirmed nonmatches.
#### Scenario: Actions exist for other owners
- **WHEN** returned actions have no owner matching the known principal
- **THEN** the badge indicates no matching ownership while its tooltip retains the returned actions
### Requirement: Read attempts are bounded and partial coverage is disclosed
Each action lookup SHALL make at most five total attempts and stop on success or session supersession. The current lookup SHALL expose `hasNext` as additional unqueried actions rather than implying that all pages were loaded.
#### Scenario: More action pages exist
- **WHEN** a successful action response sets `hasNext=true`
- **THEN** the tooltip states that additional actions exist and pagination is not enabled
### Requirement: Action forms use validated lookup selections
The Action form SHALL load team members and lookup values with the workload credential, support searchable selections, and submit selected codes rather than arbitrary typed search text. Required inputs and the date range SHALL be validated before Save.
#### Scenario: Search text is not an option
- **WHEN** the user types a value without selecting a valid lookup result
- **THEN** the text is not submitted as a valid option value
### Requirement: Action creation is explicit and preserves failure state
Saving a valid action SHALL send current-user attribution, UTC dates and a notes array, lock duplicate submission while pending and show a result toast. A failed create SHALL retain the form without automatically retrying the POST.
#### Scenario: Create fails
- **WHEN** the server rejects an action creation
- **THEN** the form remains available with the entered data and no automatic duplicate POST is issued

View File

@@ -1,6 +1,6 @@
{ {
"name": "opportunities-extension", "name": "opportunities-extension",
"version": "1.0.5", "version": "1.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -8,14 +8,17 @@ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const distDir = path.join(rootDir, "dist"); const distDir = path.join(rootDir, "dist");
const contentScript = composition.composeScript("content"); const contentScript = composition.composeScript("content");
const backgroundScript = composition.composeScript("background"); const backgroundScript = composition.composeScript("background");
const spaPageScript = composition.composeScript("spa-page");
const shortcutScript = await readFile(path.join(rootDir, "src/content/shortcut-intent.js"), "utf8");
const checkOnly = process.argv.includes("--check"); const checkOnly = process.argv.includes("--check");
const baseManifest = { const baseManifest = {
manifest_version: 3, manifest_version: 3,
name: "Opportunities Extension", name: "Opportunities Extension",
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "1.0.5", version: "1.1.0",
permissions: [ permissions: [
"bookmarks",
"cookies", "cookies",
"storage", "storage",
"webRequest", "webRequest",
@@ -27,6 +30,12 @@ const baseManifest = {
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*" "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
], ],
content_scripts: [ content_scripts: [
{
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
js: ["shortcut-intent.js"],
all_frames: false,
run_at: "document_start"
},
{ {
matches: [ matches: [
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
@@ -35,6 +44,12 @@ const baseManifest = {
js: ["content.js"], js: ["content.js"],
all_frames: true, all_frames: true,
run_at: "document_idle" run_at: "document_idle"
},
{
matches: ["https://spa.oracle.com/*"],
js: ["spa-page.js"],
all_frames: false,
run_at: "document_end"
} }
] ]
}; };
@@ -70,7 +85,7 @@ for (const target of targets) {
JSON.parse(manifestJson); JSON.parse(manifestJson);
if (checkOnly) { if (checkOnly) {
const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript }; const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript, "spa-page.js": spaPageScript, "shortcut-intent.js": shortcutScript };
for (const [file, contents] of Object.entries(expected)) { for (const [file, contents] of Object.entries(expected)) {
const actual = await readFile(path.join(distDir, target.name, file), "utf8"); 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.`); if (actual !== contents) throw new Error(`Stale output: dist/${target.name}/${file}. Run npm run build.`);
@@ -82,6 +97,8 @@ for (const target of targets) {
await mkdir(outputDir, { recursive: true }); await mkdir(outputDir, { recursive: true });
await output.writeOutput(path.join(outputDir, "content.js"), contentScript); await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript); await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript);
await output.writeOutput(path.join(outputDir, "spa-page.js"), spaPageScript);
await output.writeOutput(path.join(outputDir, "shortcut-intent.js"), shortcutScript);
await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson); await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson);
} }

View File

@@ -2,7 +2,7 @@ const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const vm = require("node:vm"); const vm = require("node:vm");
const sources = require("./sources.cjs"); const sources = require("./sources.cjs");
const vendorSources = { content: ["src/vendor/fullcalendar/index.global.min.js"], background: [] }; const vendorSources = { content: ["src/vendor/fullcalendar/index.global.min.js", "src/vendor/d3-hierarchy/d3-hierarchy.min.js"], background: [] };
const root = path.resolve(__dirname, ".."); const root = path.resolve(__dirname, "..");
@@ -17,15 +17,19 @@ function composeScript(target, { bootstrap = true } = {}) {
return `// Source: ${file}\n${source}`; return `// Source: ${file}\n${source}`;
}); });
if (target === "content") { if (target === "content") {
const hierarchySource = fs.readFileSync(path.join(root, vendorSources.content[1]), "utf8");
const hierarchyLicense = fs.readFileSync(path.join(root, "src/vendor/d3-hierarchy/LICENSE"), "utf8");
const iconLicense = fs.readFileSync(path.join(root, "src/vendor/lucide/LICENSE"), "utf8");
parts.unshift(`/* ${iconLicense} */\nlet organizationLayout;\nfunction loadOrganizationLayout() {\nif (!organizationLayout) { const exports = {}; const module = { exports };\n/* ${hierarchyLicense} */\n${hierarchySource}\norganizationLayout = exports; }\nreturn organizationLayout;\n}`);
const calendarLibrary = fs.readFileSync(path.join(root, vendorSources.content[0]), "utf8"); const calendarLibrary = fs.readFileSync(path.join(root, vendorSources.content[0]), "utf8");
const calendarLicense = fs.readFileSync(path.join(root, "src/vendor/fullcalendar/LICENSE.md"), "utf8"); const calendarLicense = fs.readFileSync(path.join(root, "src/vendor/fullcalendar/LICENSE.md"), "utf8");
parts.unshift(`function loadOpportunityCalendarLibrary() {\n/* ${calendarLicense} */\n${calendarLibrary}\nreturn FullCalendar;\n}`); parts.unshift(`function loadOpportunityCalendarLibrary() {\n/* ${calendarLicense} */\n${calendarLibrary}\nreturn FullCalendar;\n}`);
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"] const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css") .concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css", "src/content/styles/tables.css", "src/content/styles/dialogs.css")
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n"); .map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`); parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
} }
const factory = target === "content" ? "createContentApplication" : "createBackgroundApplication"; const factory = { content: "createContentApplication", background: "createBackgroundApplication", "spa-page": "createSpaPage" }[target];
const ending = bootstrap ? `${factory}().start();` : `return ${factory};`; const ending = bootstrap ? `${factory}().start();` : `return ${factory};`;
const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`; const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`;
new vm.Script(output, { filename: `${target}.js` }); new vm.Script(output, { filename: `${target}.js` });

View File

@@ -17,6 +17,14 @@ const server = http.createServer(async (request, response) => {
return; return;
} }
try { try {
if (pathname === "/org-storage-fixture.js") {
const files = ["../src/features/org-chart/model.js", "../src/features/org-chart/storage.js"];
const parts = [];
for (const file of files) parts.push(await readFile(new URL(file, import.meta.url), "utf8"));
response.setHeader("Content-Type", "text/javascript");
response.end(parts.join("\n") + "\nwindow.fixtureOrgStorage = createOrgChartStorage({ environment: { indexedDB }, model: createOrgChartModel() });");
return;
}
if (pathname === "/fixture-runtime.js") { if (pathname === "/fixture-runtime.js") {
response.setHeader("Content-Type", "text/javascript"); response.setHeader("Content-Type", "text/javascript");
response.end(`window.createFixtureApplication = ${composition.composeScript("content", { bootstrap: false })}`); response.end(`window.createFixtureApplication = ${composition.composeScript("content", { bootstrap: false })}`);

View File

@@ -1,24 +1,30 @@
module.exports = { module.exports = {
"spa-page": ["src/auth/spa-page.js"],
"content": [ "content": [
"src/features/org-chart/model.js",
"src/features/org-chart/icons.js",
"src/features/org-chart/calling-codes.js",
"src/features/org-chart/view.js",
"src/services/fusion/account-contact.js",
"src/features/preferences/table-sorting.js",
"src/shared/time-dates.js", "src/shared/time-dates.js",
"src/features/manage-time/view.js", "src/features/manage-time/view.js",
"src/shared/messages.js", "src/shared/messages.js",
"src/shared/debug-body.js", "src/shared/debug-body.js",
"src/content/contracts.js", "src/content/contracts.js",
"src/auth/session-state.js", "src/auth/session-state.js",
"src/auth/sales-common-state.js",
"src/features/opportunities/query-state.js", "src/features/opportunities/query-state.js",
"src/features/opportunities/table-state.js", "src/features/opportunities/table-state.js",
"src/features/opportunities/filter-state.js", "src/features/opportunities/filter-state.js",
"src/features/preferences/state.js", "src/features/preferences/state.js",
"src/features/preferences/request-cache.js", "src/features/preferences/request-cache.js",
"src/features/products/content-state.js", "src/features/products/content-state.js",
"src/features/ai-summary/state.js",
"src/features/consumption/plans-state.js", "src/features/consumption/plans-state.js",
"src/features/consumption/trend-state.js", "src/features/consumption/trend-state.js",
"src/features/opportunities/dashboard-state.js", "src/features/opportunities/dashboard-state.js",
"src/features/debug/state.js", "src/features/debug/state.js",
"src/features/opportunities/columns.js", "src/features/opportunities/columns.js",
"src/features/opportunities/print.js",
"src/features/opportunities/badge-content.js", "src/features/opportunities/badge-content.js",
"src/content/fusion-page.js", "src/content/fusion-page.js",
"src/content/modal.js", "src/content/modal.js",
@@ -26,9 +32,9 @@ module.exports = {
"src/content/icons.js", "src/content/icons.js",
"src/content/svg.js", "src/content/svg.js",
"src/content/styles.js", "src/content/styles.js",
"src/content/dialog-drag.js",
"src/auth/content-session.js", "src/auth/content-session.js",
"src/auth/current-user.js", "src/auth/current-user.js",
"src/auth/sales-common.js",
"src/auth/token-relay.js", "src/auth/token-relay.js",
"src/auth/xsrf-content.js", "src/auth/xsrf-content.js",
"src/platform/content-messages.js", "src/platform/content-messages.js",
@@ -65,7 +71,6 @@ module.exports = {
"src/features/products/ramp-projection.js", "src/features/products/ramp-projection.js",
"src/features/products/forecast-update.js", "src/features/products/forecast-update.js",
"src/features/products/ramp-projection-view.js", "src/features/products/ramp-projection-view.js",
"src/features/ai-summary/content.js",
"src/features/consumption/plans-action.js", "src/features/consumption/plans-action.js",
"src/features/install-base/action.js", "src/features/install-base/action.js",
"src/features/install-base/view.js", "src/features/install-base/view.js",
@@ -88,6 +93,8 @@ module.exports = {
"src/content/application.js" "src/content/application.js"
], ],
"background": [ "background": [
"src/features/org-chart/model.js",
"src/features/org-chart/storage.js",
"src/shared/time-dates.js", "src/shared/time-dates.js",
"src/services/fusion/time-entries.js", "src/services/fusion/time-entries.js",
"src/shared/messages.js", "src/shared/messages.js",
@@ -107,6 +114,7 @@ module.exports = {
"src/services/fusion/sr-details.js", "src/services/fusion/sr-details.js",
"src/services/fusion/sr-create.js", "src/services/fusion/sr-create.js",
"src/services/spa/current-user.js", "src/services/spa/current-user.js",
"src/services/spa/page-client.js",
"src/services/spa/consumption.js", "src/services/spa/consumption.js",
"src/services/spa/response-shapes.js", "src/services/spa/response-shapes.js",
"src/services/http/background-json.js", "src/services/http/background-json.js",

View File

@@ -0,0 +1,273 @@
// Synthetic browser verification; install Playwright separately or provide NODE_PATH.
// No Oracle requests, credentials or real customer data are used.
const { chromium } = require("playwright");
const { spawn } = require("node:child_process");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
(async () => {
const server = spawn(process.execPath, ["scripts/preview.mjs"], { cwd: path.resolve(__dirname, ".."), stdio: ["ignore", "pipe", "inherit"] });
let browser;
const output = fs.mkdtempSync(path.join(os.tmpdir(), "org-chart-verification-"));
try {
const url = await new Promise((resolve, reject) => {
server.once("error", reject); server.once("exit", code => reject(new Error("Preview exited: " + code)));
server.stdout.on("data", chunk => { const found = String(chunk).match(/http:\/\/127\.0\.0\.1:\d+\//); if (found) resolve(found[0]); });
});
browser = await chromium.launch({ headless: true, ...(process.env.TEST_BROWSER_CHANNEL ? { channel: process.env.TEST_BROWSER_CHANNEL } : {}) });
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
const errors = []; page.on("pageerror", error => errors.push(error.message));
// Any external request is a fixture regression, not a permitted live test.
await page.route("**/*", route => new URL(route.request().url()).origin === new URL(url).origin ? route.continue() : route.abort());
page.setDefaultTimeout(10000);
await page.goto(url);
await page.evaluate(() => new Promise((resolve,reject) => {
const request=indexedDB.open("opportunities-extension-organizations",1);
request.onupgradeneeded=()=>request.result.createObjectStore("charts",{keyPath:"key"});
request.onerror=()=>reject(request.error);
request.onsuccess=()=>{
const db=request.result, tx=db.transaction("charts","readwrite");
tx.objectStore("charts").put({key:"legacy-migration",schemaVersion:1,revision:3,nodes:[{id:"legacy",parentId:null,name:"Legacy",role:"Old title",emailAddress:"old@example.invalid",notes:"Retain notes",collapsed:false}]});
tx.oncomplete=()=>{db.close();resolve();}; tx.onerror=()=>reject(tx.error);
};
}));
const open = () => page.getByRole("button", { name: "View customer organization chart", exact: true }).first().click();
const dialog = page.locator(".oe-org-dialog");
const button = label => dialog.getByRole("button", { name: label, exact: true });
const editor = page.locator(".oe-org-editor");
async function addPerson(name, role, notes = "", parent) {
await button(parent ? "Add report to " + parent : "Add person").click();
const search = editor.getByRole("combobox", { name: "Name", exact: true });
await search.fill(name);
await page.locator("#oe-org-name-options").getByRole("option").filter({ hasText: name }).click();
assert.equal(await editor.getByRole("textbox", { name: "Job Title", exact: true }).inputValue(), role);
assert.equal(await editor.getByRole("textbox", { name: "Job Title", exact: true }).evaluate(input => input.readOnly), true);
await editor.getByRole("textbox", { name: "Notes", exact: true }).fill(notes);
await editor.getByRole("button", { name: "Add person", exact: true }).click();
}
async function save() {
await button("Save chart").click();
await page.waitForFunction(() => document.querySelector(".oe-org-footer .oe-org-status")?.textContent.startsWith("Saved locally:"));
}
await open();
await dialog.getByRole("heading", { name: "No people yet" }).waitFor();
assert.deepEqual(await page.evaluate(()=>window.fixtureContactQueries || []),[]);
await button("Add person").click();
const nameSearch=editor.getByRole("combobox",{name:"Name",exact:true});
await editor.getByRole("button", { name: "Create Contact", exact: true }).click();
const contactDialog = page.locator(".oe-contact-dialog");
await contactDialog.getByRole("button", { name: "Save", exact: true }).click();
assert.equal(await contactDialog.locator('[aria-invalid="true"]').count(), 3);
assert.equal(await contactDialog.locator('#oe-contact-validation').textContent(), "Complete the highlighted required fields with valid values.");
await contactDialog.getByRole("textbox", { name: "First Name", exact: true }).fill("Test");
assert.equal(await contactDialog.locator('[aria-invalid="true"]').count(), 2);
assert.equal(await contactDialog.locator("#oe-contact-workCountry option").count(), 246);
assert.equal(await contactDialog.locator("#oe-contact-mobileCountry option").count(), 246);
const country = contactDialog.getByRole("combobox", { name: "Work Phone country code", exact: true });
await country.fill("Brazil");
await contactDialog.getByRole("option", { name: "BR · Brazil (+55)", exact: true }).click();
assert.equal(await contactDialog.locator("#oe-contact-workCountry").inputValue(), "BR");
assert.equal(await contactDialog.locator("#oe-contact-mobileCountry").inputValue(), "");
assert.equal(await contactDialog.locator('input[name="primaryPhone"]').count(), 0);
await page.screenshot({path:path.join(output,"create-contact.png")});
await contactDialog.getByRole("button", { name: "Cancel", exact: true }).click();
assert.equal(await editor.getByRole("button", { name: "Create Contact", exact: true }).evaluate(item => item === document.activeElement), true);
await nameSearch.fill("A"); await nameSearch.fill("Al"); await nameSearch.fill("Alex");
await page.locator("#oe-org-name-options [role=option]").waitFor();
assert.deepEqual(await page.evaluate(()=>window.fixtureContactQueries),["Alex"]);
await page.evaluate(()=>{window.fixtureContactCompleted=[]; window.fixtureContactDelay={Alex:1400};});
await nameSearch.fill("Alex");
await page.waitForFunction(()=>window.fixtureContactQueries.length===2);
await nameSearch.fill("Taylor");
await page.locator("#oe-org-name-options [role=option]").filter({hasText:"Taylor Chen"}).waitFor();
await page.waitForFunction(()=>window.fixtureContactCompleted.includes("Alex"));
assert.equal(await page.locator("#oe-org-name-options [role=option]").filter({hasText:"Alex Morgan"}).count(),0);
await page.evaluate(()=>{window.fixtureContactsFailure=true; window.fixtureContactDelay={};});
await nameSearch.fill("Alex");
await page.locator("#oe-org-name-options [role=status]").filter({hasText:"failed with status 503"}).waitFor();
await page.evaluate(()=>window.fixtureContactsFailure=false);
await nameSearch.fill("Jordan");
await page.locator("#oe-org-name-options [role=option]").filter({hasText:"Jordan Silva"}).waitFor();
await editor.getByRole("button", {name:"Cancel",exact:true}).click();
await button("Add person").click();
await editor.getByRole("combobox", { name: "Name", exact: true }).fill("Not a contact");
await editor.getByRole("button", { name: "Add person", exact: true }).click();
assert.equal(await page.locator(".oe-org-node").count(), 0);
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
await addPerson("Alex Morgan", "Chief Technology Officer", "Decision maker for the cloud program.");
await addPerson("Taylor Chen", "Infrastructure Director", "Owns the migration roadmap.", "Alex Morgan");
await addPerson("Jordan Silva", "Data and Analytics Director", "Coordinates the analytics team.", "Alex Morgan");
await addPerson("Sam Rivera", "Cloud Architect", "Technical workshop: November 12.", "Taylor Chen");
const queriesBeforeOpportunities = await page.evaluate(() => window.fixtureContactQueries.length);
await page.evaluate(() => { window.fixtureOpportunitiesDelay = 400; });
await button("View opportunities for Alex Morgan").click();
const contactOpportunities = page.locator(".oe-contact-opportunities");
await contactOpportunities.locator('table[aria-busy="true"]').waitFor();
assert.equal(await contactOpportunities.locator("tbody .oe-contact-skeleton").count(),35);
await contactOpportunities.getByText("6 opportunities", {exact:true}).waitFor();
assert.equal(await contactOpportunities.locator(".oe-contact-skeleton").count(),0);
await page.evaluate(() => { window.fixtureOpportunitiesDelay = 0; });
assert.equal(await page.evaluate(() => window.fixtureContactQueries.length), queriesBeforeOpportunities, "Opportunity lookup must reuse the person's PartyNumber without searching contacts");
assert.equal(await contactOpportunities.locator("tbody tr").count(), 6);
const presentation = await contactOpportunities.locator("table").evaluate(table => ({
marker: table.getAttribute("data-table-layout"),
textHeader: getComputedStyle(table.querySelector("th")).textAlign,
moneyHeader: getComputedStyle(table.querySelector('th[data-align="money"]')).textAlign,
moneyCell: getComputedStyle(table.querySelector('td[data-align="money"]')).textAlign,
nowrap: getComputedStyle(table.querySelector(".opportunities-extension-sort-label")).whiteSpace
}));
assert.deepEqual(presentation,{marker:"standard",textHeader:"left",moneyHeader:"right",moneyCell:"right",nowrap:"nowrap"});
await page.screenshot({path:path.join(output,"shared-table.png")});
assert.equal(await contactOpportunities.getByRole("link", {name:"Contact opportunity 0",exact:true}).getAttribute("target"), "_blank");
assert.ok((await contactOpportunities.getByRole("link", {name:"Contact opportunity 0",exact:true}).getAttribute("href")).endsWith("?puid=CONTACT0"));
assert.deepEqual((await page.evaluate(()=>window.fixtureOpportunitiesByContact)).map(url=>new URL(url).searchParams.get("offset")), ["0","5"]);
await contactOpportunities.getByRole("button",{name:"Sort by OptyNumber",exact:true}).click();
await contactOpportunities.getByRole("button",{name:"Sort by OptyNumber, ascending",exact:true}).click();
assert.equal(await contactOpportunities.locator("tbody tr").first().locator("td").first().textContent(),"CONTACT5");
assert.equal(new URL((await page.evaluate(()=>window.fixtureOpportunitiesByContact))[0]).searchParams.get("q"), "(OpportunityContact.ContactPartyNumber='18000')");
await contactOpportunities.getByRole("button", {name:"Close opportunities",exact:true}).click();
await button("View opportunities for Alex Morgan").click();
await contactOpportunities.getByText("6 opportunities",{exact:true}).waitFor();
assert.equal(await contactOpportunities.locator("tbody tr").first().locator("td").first().textContent(),"CONTACT5");
await contactOpportunities.getByRole("button", {name:"Close opportunities",exact:true}).click();
await button("Fit chart to view").click();
assert.equal(await page.locator(".oe-org-node").count(), 4);
await save();
await button("Edit Alex Morgan").hover();
assert.equal(await page.locator(".oe-org-tooltip").innerText(), "Decision maker for the cloud program.");
assert.equal(await dialog.getByRole("button", { name: /inline/i }).count(), 0);
await button("Edit Alex Morgan").click();
await page.waitForFunction(() => document.querySelector("#oe-org-emailAddress").value === "contact0@example.invalid");
assert.equal(await editor.getByRole("combobox", { name: "Name", exact: true }).inputValue(), "Alex Morgan");
await editor.getByRole("textbox", { name: "Notes", exact: true }).fill("Updated contact notes");
await button("Apply changes").click();
await page.evaluate(() => {
window.fixtureOriginalOrgSave = window.fixtureOrgStorage.save;
window.fixtureOrgStorage.save = async () => { throw new Error("Synthetic storage failure"); };
});
await button("Save chart").click();
await page.locator(".oe-org-status.oe-org-error").filter({ hasText: "Synthetic storage failure" }).waitFor();
assert.equal(await button("Save chart").isEnabled(), true);
await page.evaluate(() => { window.fixtureOrgStorage.save = window.fixtureOriginalOrgSave; });
await button("Collapse reports for Taylor Chen").click();
assert.equal(await page.locator(".oe-org-node").count(), 3);
await save();
await page.reload(); await open();
await button("Expand reports for Taylor Chen").waitFor();
assert.equal(await page.locator(".oe-org-node").count(), 3);
await button("Edit Alex Morgan").click();
await page.waitForFunction(() => document.querySelector("#oe-org-role").value === "Chief Technology Officer");
await editor.getByRole("button", {name:"Cancel",exact:true}).click();
await button("Expand reports for Taylor Chen").click();
await button("Edit Taylor Chen").click();
await page.waitForFunction(() => document.querySelector("#oe-org-emailAddress").value === "contact1@example.invalid");
assert.deepEqual(await editor.locator("#oe-org-parent option").allTextContents(), ["No manager (top level)", "Alex Morgan - Chief Technology Officer", "Jordan Silva - Data and Analytics Director"]);
await editor.getByRole("textbox", { name: "Notes", exact: true }).fill("Unapplied note");
await button("Close organization chart").click();
await page.getByRole("button", { name: "Keep editing", exact: true }).click();
assert.equal(await editor.getByRole("textbox", { name: "Notes", exact: true }).inputValue(), "Unapplied note");
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
// Reparent through the searchable select, then restore the original manager.
await button("Edit Sam Rivera").click();
await editor.getByRole("combobox", { name: "Reports to", exact: true }).click();
await page.getByRole("option", { name: "Jordan Silva - Data and Analytics Director", exact: true }).click();
await editor.getByRole("button", { name: "Apply changes", exact: true }).click();
await button("Edit Sam Rivera").click();
await editor.getByRole("combobox", { name: "Reports to", exact: true }).click();
await page.getByRole("option", { name: "Taylor Chen - Infrastructure Director", exact: true }).click();
await editor.getByRole("button", { name: "Apply changes", exact: true }).click();
await button("Fit chart to view").click();
await save();
const boxes = await page.locator(".oe-org-node").evaluateAll(nodes => nodes.map(node => { const r = node.getBoundingClientRect(); return { x:r.x,y:r.y,w:r.width,h:r.height }; }));
for (let a=0;a<boxes.length;a++) for(let b=a+1;b<boxes.length;b++) {
const p=boxes[a],q=boxes[b]; assert.ok(p.x+p.w<=q.x || q.x+q.w<=p.x || p.y+p.h<=q.y || q.y+q.h<=p.y, "Cards must not overlap");
}
await page.mouse.move(5,5);
await page.screenshot({ path: path.join(output, "light.png") });
await page.locator(".opportunities-extension-modal").first().evaluate(node => node.dataset.theme="dark");
await button("Edit Jordan Silva").click();
await page.keyboard.press("Control+d");
const debug = page.locator(".opportunities-extension-debug-panel");
await debug.getByRole("button", { name: "Close debug", exact: true }).waitFor();
assert.equal(await debug.evaluate(node => !node.closest("[inert]") && node.parentElement.classList.contains("opportunities-extension-modal")), true);
assert.equal(await page.evaluate(() => !!document.elementFromPoint(innerWidth / 2, innerHeight / 2).closest(".opportunities-extension-debug-panel")), true);
await debug.getByRole("button", { name: "Close debug", exact: true }).click();
assert.equal(await editor.isVisible(), true);
await editor.getByRole("button", { name: "Delete person", exact: true }).click();
await page.keyboard.press("Control+d");
assert.equal(await page.evaluate(() => !!document.elementFromPoint(innerWidth / 2, innerHeight / 2).closest(".opportunities-extension-debug-panel")), true);
await page.keyboard.press("Escape");
assert.equal(await page.getByRole("alertdialog").isVisible(), true);
await page.getByRole("alertdialog").getByRole("button", { name: "Keep editing", exact: true }).click();
await page.screenshot({ path: path.join(output, "dark-editor.png") });
await page.setViewportSize({width:390,height:844});
await page.screenshot({ path: path.join(output, "mobile-editor.png") });
const bounds = await dialog.boundingBox(); assert.ok(bounds.x>=0 && bounds.x+bounds.width<=390 && bounds.y+bounds.height<=844);
await page.setViewportSize({width:1440,height:960});
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
await button("Delete Taylor Chen").click();
await page.getByRole("alertdialog").getByRole("button", { name: "Keep editing", exact: true }).click();
assert.equal(await page.locator(".oe-org-node").count(), 4);
await button("Delete Taylor Chen").click();
await page.getByRole("alertdialog").getByRole("button", { name: "Delete person", exact: true }).click();
await save();
const storageResult = await page.evaluate(async () => {
const identity={principal:"example@example.invalid",customerId:"100000810210835"};
const store=window.fixtureOrgStorage, chart=(await store.load(identity)).chart;
const sam=chart.nodes.find(n=>n.name==="Sam Rivera"), alex=chart.nodes.find(n=>n.name==="Alex Morgan");
let conflict=false; try { await store.save({...identity,nodes:[],revision:0}); } catch { conflict=true; }
return {conflict,promoted:sam.parentId===alex.id,notes:sam.notes,contactPartyId:sam.contactPartyId,
independentUser:(await store.load({...identity,principal:"different@example.invalid"})).chart.nodes.length,
independentCustomer:(await store.load({...identity,customerId:"another-customer"})).chart.nodes.length,
afterConflict:(await store.load(identity)).chart.nodes.length};
});
assert.deepEqual(storageResult,{conflict:true,promoted:true,notes:"Technical workshop: November 12.",contactPartyId:"2003",independentUser:0,independentCustomer:0,afterConflict:3});
const rawRecords = await page.evaluate(() => new Promise((resolve,reject) => {
const request=indexedDB.open("opportunities-extension-organizations",3);
request.onerror=()=>reject(request.error);
request.onsuccess=()=>{
const db=request.result, tx=db.transaction("charts","readonly"), all=tx.objectStore("charts").getAll();
all.onsuccess=()=>{resolve(all.result);db.close();}; all.onerror=()=>reject(all.error);
};
}));
assert.ok(rawRecords.every(record=>record.nodes.every(node=>!("role" in node)&&!("emailAddress" in node)&&typeof node.jobTitle === "string")));
assert.equal(rawRecords.find(record=>record.key==="legacy-migration").nodes[0].notes,"Retain notes");
assert.equal(rawRecords.find(record=>record.key==="legacy-migration").revision,3);
assert.equal(rawRecords.find(record=>record.key==="legacy-migration").nodes[0].jobTitle,"Old title");
await button("Close organization chart").click();
await page.getByRole("button", { name: "View customer organization chart", exact: true }).nth(1).click();
await button("Edit Sam Rivera").waitFor(); // same customer, different opportunity
const queriesBefore=await page.evaluate(()=>window.fixtureContactQueries.filter(q=>q==="Sam Rivera").length);
await button("Edit Sam Rivera").click();
await page.waitForFunction(()=>document.querySelector("#oe-org-emailAddress").value==="contact3@example.invalid");
await editor.getByRole("button",{name:"Cancel",exact:true}).click();
await button("Edit Sam Rivera").click();
await page.waitForFunction(()=>document.querySelector("#oe-org-emailAddress").value==="contact3@example.invalid");
assert.equal(await page.evaluate(()=>window.fixtureContactQueries.filter(q=>q==="Sam Rivera").length),queriesBefore+2);
assert.equal(await button("Save chart").isDisabled(),true);
await editor.getByRole("button",{name:"Cancel",exact:true}).click();
await page.evaluate(()=>{
window.fixtureContactOverrides={"2003":{PartyName:"Samuel Rivera",JobTitle:"Principal Architect"}};
window.fixtureContactDelay={"Sam Rivera":250};
});
await button("Edit Sam Rivera").click();
await editor.getByRole("textbox",{name:"Notes",exact:true}).fill("Pending note preserved");
await page.waitForFunction(()=>document.querySelector("#oe-org-role").value==="Principal Architect");
assert.equal(await editor.getByRole("combobox",{name:"Name",exact:true}).inputValue(),"Samuel Rivera");
assert.equal(await editor.getByRole("textbox",{name:"Notes",exact:true}).inputValue(),"Pending note preserved");
assert.equal(await button("Edit Samuel Rivera").count(),1);
await save();
const updated=await page.evaluate(async()=>{
const chart=(await window.fixtureOrgStorage.load({principal:"example@example.invalid",customerId:"100000810210835"})).chart;
return chart.nodes.find(node=>node.contactPartyId==="2003");
});
assert.equal(updated.name,"Samuel Rivera");assert.equal(updated.jobTitle,"Principal Architect");
assert.equal(updated.notes,"Pending note preserved");assert.equal("emailAddress" in updated,false);
await button("Close organization chart").click();
assert.deepEqual(errors, []);
console.log("PASS: contact autocomplete, chart creation, reparenting, editing, notes, collapse, reload, failed-save recovery, unsaved protection, deletion, account/user isolation and revision conflict.");
console.log("Screenshots: " + output);
} finally { await browser?.close(); server.kill(); }
})().catch(error => { console.error(error); process.exitCode=1; });

View File

@@ -8,9 +8,16 @@ function createAuthConsumerSession({
fetchJson, fetchJson,
supportsFirefoxCookieStoreTabs, supportsFirefoxCookieStoreTabs,
tabsCreate, tabsCreate,
tabsUpdate,
tabsRemove, tabsRemove,
responseHeaders,
cookieChanges,
inspectPage,
setTimeout,
clearTimeout,
wait wait
}) { }) {
const sessionTabs = new Map();
async function requestConsumerTokenService(sender) { async function requestConsumerTokenService(sender) {
const debugRequests = []; const debugRequests = [];
@@ -150,7 +157,8 @@ function createAuthConsumerSession({
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs); debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
} }
await wait(retryDelayMs); if (sessionTab) await waitForConsumerTokenSession(sessionTab, debugEntry, retryDelayMs);
else await wait(retryDelayMs);
} }
} }
@@ -160,7 +168,7 @@ function createAuthConsumerSession({
const debugEntry = debugRequests[debugRequests.length - 1]; const debugEntry = debugRequests[debugRequests.length - 1];
try { try {
await tabsRemove(sessionTab.id); await closeConsumerTokenSessionTab(sessionTab.id);
if (debugEntry) { if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = receivedValidJson debugEntry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json" ? "closed-after-valid-json"
@@ -212,7 +220,7 @@ function createAuthConsumerSession({
} }
} }
function openConsumerTokenSessionTab(sender) { async function openConsumerTokenSessionTab(sender) {
const details = { const details = {
url: CUSTOMER_TOKEN_SESSION_URL, url: CUSTOMER_TOKEN_SESSION_URL,
active: false active: false
@@ -226,8 +234,184 @@ function createAuthConsumerSession({
details.cookieStoreId = sender.tab.cookieStoreId; details.cookieStoreId = sender.tab.cookieStoreId;
} }
return tabsCreate(details); const tab = await tabsCreate(details);
if (Number.isInteger(tab?.id)) {
const state = { failedStatus: 0, reloads: 0, lastReloadAt: -Infinity, readyVersion: 0, consumedVersion: 0, closed: false };
const signal = reason => {
state.readyVersion++;
state.readyReason = reason;
state.wake?.();
};
state.listener = details => {
if (details.tabId !== tab.id
|| !/^https:\/\/spa\.oracle\.com\//.test(details.url)) return;
if (details.type === "main_frame") {
if (details.statusCode === 401 || details.statusCode === 403) {
state.failedStatus = details.statusCode;
state.consumedVersion = state.readyVersion;
state.wake?.();
} else if (details.statusCode >= 200 && details.statusCode < 300) {
state.failedStatus = 0;
if (!details.url.startsWith("https://spa.oracle.com/cloudgate/")) signal("spa-document-response");
}
} else if (details.type === "xmlhttprequest" && details.statusCode === 200
&& details.url.split("?")[0] === "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current"
&& details.responseHeaders?.some(header => header.name.toLowerCase() === "content-type" && /application\/json/i.test(header.value))) {
state.failedStatus = 0;
signal("spa-identity-json-response");
}
};
state.cookieListener = change => {
const cookie = change.cookie;
if (change.removed || !cookie?.value || !cookie.secure || !cookie.httpOnly
|| cookie.domain?.replace(/^\./, "") !== "spa.oracle.com" || !/session/i.test(cookie.name)
|| (cookie.expirationDate != null && cookie.expirationDate <= Date.now() / 1000)
|| (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId)) return;
signal("spa-session-cookie-changed");
};
try {
responseHeaders?.addListener(state.listener, { urls: ["https://spa.oracle.com/*"], types: ["main_frame", "xmlhttprequest"] }, ["responseHeaders"]);
cookieChanges?.addListener(state.cookieListener);
sessionTabs.set(tab.id, state);
} catch (error) {
responseHeaders?.removeListener?.(state.listener);
cookieChanges?.removeListener(state.cookieListener);
await tabsRemove(tab.id).catch(() => {});
throw error;
}
}
return tab;
} }
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab }; async function recoverConsumerTokenSessionTab(tab, debugEntry, reason = "") {
const state = sessionTabs.get(tab.id);
if (!state || (!state.failedStatus && !reason) || Date.now() - state.lastReloadAt < 2000) return false;
const status = state.failedStatus;
state.failedStatus = 0;
state.consumedVersion = state.readyVersion;
state.lastReloadAt = Date.now();
state.reloads++;
if (debugEntry) {
debugEntry.metadata.sessionBootstrapReloadCount = String(state.reloads);
debugEntry.metadata.sessionBootstrapReloadReason = reason || `SPA navigation returned HTTP ${status}`;
}
try {
// Restart at the entry point; OAuth callback URLs may contain single-use state.
await tabsUpdate(tab.id, { url: CUSTOMER_TOKEN_SESSION_URL, active: false });
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = "restarted-at-spa-home";
return true;
} catch (error) {
state.failedStatus = status;
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = `restart-failed: ${error.message || "unknown error"}`;
return false;
}
}
async function waitForSpaPage(tab, debugEntry) {
const startedAt = Date.now();
const deadline = startedAt + 120000;
let restartedDocument = null;
let restarts = 0;
if (debugEntry) {
delete debugEntry.metadata.nextRetryDelayMs;
debugEntry.metadata.sessionWaitTimeoutMs = "120000";
debugEntry.metadata.sessionWaitReason = "inspecting-spa-document";
}
while (Date.now() < deadline) {
const state = sessionTabs.get(tab.id);
if (state?.closed) break;
let page;
try { page = await inspectPage(tab); } catch { /* Cross-origin SSO pages have no SPA receiver. */ }
if (debugEntry) debugEntry.metadata.spaPageState = page?.status || "loading-or-login";
const documentId = typeof page?.documentId === "string" ? page.documentId : null;
const alreadyRead = documentId && documentId === tab.spaDocumentId;
if (state?.failedStatus || page?.status === "error" || alreadyRead) {
if (restarts < 3 && (state?.failedStatus || documentId !== restartedDocument)) {
const previousReloads = state?.reloads || 0;
const restarted = await recoverConsumerTokenSessionTab(tab, debugEntry,
state?.failedStatus ? "" : alreadyRead ? "Identity rejected in this SPA document" : "SPA document displays an authorization error");
restarts += (state?.reloads || 0) - previousReloads;
if (restarted) restartedDocument = documentId;
}
} else if (page?.status === "ready" && documentId) {
// Confirm the parsed application document without waiting for visual assets.
await wait(100);
let confirmed;
try { confirmed = await inspectPage(tab); } catch { /* A redirect started during validation. */ }
if (confirmed?.status === "ready" && confirmed.documentId === documentId && !state?.failedStatus) {
tab.spaDocumentId = documentId;
if (debugEntry) {
debugEntry.metadata.sessionWaitReason = "spa-document-verified";
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
}
return;
}
}
await wait(1000);
}
const error = new Error("SPA page did not finish authentication. No further identity request was sent.");
error.code = "SPA_PAGE_NOT_READY";
throw error;
}
async function waitForConsumerTokenSession(tab, debugEntry, retryDelay) {
const state = sessionTabs.get(tab.id);
if (!state || !responseHeaders?.addListener || !setTimeout) return wait(retryDelay);
const startedAt = Date.now();
const deadline = startedAt + 20000;
if (debugEntry) {
delete debugEntry.metadata.nextRetryDelayMs;
debugEntry.metadata.sessionWaitTimeoutMs = "20000";
}
let restarts = 0;
let reason = "timeout-fallback";
// Do not issue API probes while SSO redirects are still establishing the session.
while (!state.closed && Date.now() < deadline) {
if (state.failedStatus) {
if (restarts >= 3) {
await wait(Math.max(0, deadline - Date.now()));
break;
}
await recoverConsumerTokenSessionTab(tab, debugEntry);
restarts++;
if (state.failedStatus) await wait(2000);
continue;
}
if (state.readyVersion > state.consumedVersion) {
await wait(250);
if (state.failedStatus) continue;
state.consumedVersion = state.readyVersion;
reason = state.readyReason;
break;
}
let timer;
try {
await new Promise(resolve => {
state.wake = resolve;
timer = setTimeout(resolve, Math.max(0, deadline - Date.now()));
});
} finally {
clearTimeout?.(timer);
state.wake = null;
}
}
if (debugEntry) {
debugEntry.metadata.sessionWaitReason = reason;
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
}
}
async function closeConsumerTokenSessionTab(id) {
const state = sessionTabs.get(id);
sessionTabs.delete(id);
if (state) {
state.closed = true;
state.wake?.();
responseHeaders?.removeListener?.(state.listener);
cookieChanges?.removeListener(state.cookieListener);
}
await tabsRemove(id);
}
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab, recoverConsumerTokenSessionTab, waitForConsumerTokenSession, waitForSpaPage, closeConsumerTokenSessionTab };
} }

View File

@@ -19,6 +19,7 @@ function createAuthContentSession({
return token; return token;
}) })
.catch((error) => { .catch((error) => {
authSessionState.tokenRelayRequest = null;
authSessionState.accessToken = ""; authSessionState.accessToken = "";
authSessionState.principal = ""; authSessionState.principal = "";
authSessionState.authStatus = AUTH_STATUS.unauthenticated; authSessionState.authStatus = AUTH_STATUS.unauthenticated;

View File

@@ -1,17 +1,32 @@
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) { function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes, environment }) {
const { setTimeout } = environment;
let pending = null; let pending = null;
let allowed = false; let allowed = false;
let userId = ""; let userId = "";
function ensure() { function ensure() {
if (!pending) { if (!pending) {
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => { pending = (async () => {
let retryDelay = 1000;
// Keep a single pending identity request until the response is usable.
for (;;) {
try {
const response = await sendRuntimeMessage({ type: messageTypes.requestCurrentUser });
appendBackgroundRequestLog(response?.debugRequests); appendBackgroundRequestLog(response?.debugRequests);
const roles = response?.user?.roles; const user = response?.user;
userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : ""; if (response?.ok === true && typeof user?.id === "string" && user.id.trim()
allowed = response?.ok === true && Array.isArray(roles) && typeof user.name === "string" && user.name.trim()
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP"); && Array.isArray(user.roles) && user.roles.every(role => typeof role === "string" && role.trim())) {
userId = user.id;
allowed = !user.roles.includes("SALES_REP");
return allowed; return allowed;
}).catch(() => { allowed = false; return false; }); }
} catch (error) {
appendBackgroundRequestLog(error?.debugRequests);
}
await new Promise(resolve => setTimeout(resolve, retryDelay));
retryDelay = Math.min(retryDelay * 2, 10000);
}
})();
} }
return pending; return pending;
} }

View File

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

View File

@@ -1,33 +0,0 @@
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 };
}

52
src/auth/spa-page.js Normal file
View File

@@ -0,0 +1,52 @@
function createSpaPage() {
function start() {
if (location.origin !== "https://spa.oracle.com" || window.top !== window) return;
const runtime = (globalThis.browser || globalThis.chrome)?.runtime;
if (!runtime) return;
const documentId = `${performance.timeOrigin}:${location.pathname}`;
let identityRequest = null;
function inspect() {
const text = `${document.title}\n${document.body?.innerText || ""}`.trim();
let status = "loading";
if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error";
else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname)
|| /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title)
|| document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login";
else if ((document.readyState === "interactive" || document.readyState === "complete") && text
&& document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready";
return { status, documentId };
}
runtime.onMessage.addListener((message, sender, reply) => {
if (sender.id !== runtime.id) return false;
if (message?.type === "opportunitiesExtension.inspectSpaPage") {
reply(inspect());
return false;
}
if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false;
const page = inspect();
if (page.status !== "ready" || page.documentId !== message.documentId) {
reply({ error: "SPA page is not ready or has navigated." });
return false;
}
// A document is probed at most once; failed identity reads require a fresh navigation.
if (!identityRequest) identityRequest = (async () => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal
});
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers),
contentType: response.headers.get("content-type") || "", body: await response.text() };
} catch (error) {
return { error: error.message || "Unable to read SPA identity." };
} finally { clearTimeout(timer); }
})();
identityRequest.then(reply);
return true;
});
}
return { start };
}

View File

@@ -34,17 +34,27 @@ function createAuthTokenRelay({
} }
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url); const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
const controller = environment.AbortController ? new environment.AbortController() : null;
let timeoutId;
const timeout = new Promise((_, reject) => {
if (!environment.setTimeout) return;
timeoutId = environment.setTimeout(() => {
reject(new Error("Token relay timed out after 30 seconds. Please try again."));
controller?.abort();
}, 30000);
});
try { try {
const response = await fetch(url, { const response = await Promise.race([fetch(url, {
method: "GET", method: "GET",
credentials: "include", credentials: "include",
...(controller ? { signal: controller.signal } : {}),
headers: { headers: {
"x-xsrf-token": xsrfTokenSource.token "x-xsrf-token": xsrfTokenSource.token
} }
}); }), timeout]);
const responseBody = await readResponseBodyForDebug(response); const responseBody = await Promise.race([readResponseBodyForDebug(response), timeout]);
if (!response.ok) { if (!response.ok) {
logRequestSuccess(requestEntry, response, responseBody); logRequestSuccess(requestEntry, response, responseBody);
@@ -63,8 +73,9 @@ function createAuthTokenRelay({
let responseData; let responseData;
try { try {
responseData = await response.json(); responseData = await Promise.race([response.json(), timeout]);
} catch (error) { } catch (error) {
if (controller?.signal.aborted) throw error;
logRequestSuccess(requestEntry, response, responseBody); logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted) { if (!refreshAttempted) {
@@ -109,6 +120,8 @@ function createAuthTokenRelay({
} catch (error) { } catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed."); logRequestFailure(requestEntry, error.message || "Request failed.");
throw error; throw error;
} finally {
if (timeoutId !== undefined) environment.clearTimeout?.(timeoutId);
} }
} }
} }

View File

@@ -1,16 +1,31 @@
function createBackgroundApplication(overrides = {}) { function createBackgroundApplication(overrides = {}) {
const environment = { const environment = {
indexedDB: globalThis.indexedDB,
fetch: globalThis.fetch, fetch: globalThis.fetch,
browser: globalThis.browser, browser: globalThis.browser,
chrome: globalThis.chrome, chrome: globalThis.chrome,
setTimeout: globalThis.setTimeout, setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
...overrides ...overrides
}; };
const messageTypes = createMessageTypes(); const messageTypes = createMessageTypes();
const orgChartStorage = createOrgChartStorage({ environment, model: createOrgChartModel() });
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) }); const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
const contracts = createBackgroundContracts(); const contracts = createBackgroundContracts();
const spaPageClient = createSpaPageClient({
sendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args),
getHttp: () => httpBackgroundJson
});
const spaCurrentUser = createSpaCurrentUser({
sessionStorage: (environment.browser || environment.chrome)?.storage?.session,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
fetchInTab: (tab, logs) => spaPageClient.requestIdentity(tab, logs),
openSessionTab: sender => authConsumerSession.openConsumerTokenSessionTab(sender),
waitForSession: (tab, entry) => authConsumerSession.waitForSpaPage(tab, entry),
closeSessionTab: id => authConsumerSession.closeConsumerTokenSessionTab(id),
sessionUrl: contracts.CUSTOMER_TOKEN_SESSION_URL
});
const requestCurrentUser = sender => spaCurrentUser.requestCurrentUser(sender);
const productsCacheState = createProductsCacheState(); const productsCacheState = createProductsCacheState();
const authConsumerSession = createAuthConsumerSession({ const authConsumerSession = createAuthConsumerSession({
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS, CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
@@ -22,7 +37,13 @@ function createBackgroundApplication(overrides = {}) {
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args), tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
responseHeaders: (environment.browser || environment.chrome)?.webRequest?.onHeadersReceived,
cookieChanges: (environment.browser || environment.chrome)?.cookies?.onChanged,
inspectPage: tab => spaPageClient.inspect(tab),
setTimeout: environment.setTimeout,
clearTimeout: environment.clearTimeout,
wait: (...args) => platformBackgroundApi.wait(...args) wait: (...args) => platformBackgroundApi.wait(...args)
}); });
const authSpaCredentials = createAuthSpaCredentials({ const authSpaCredentials = createAuthSpaCredentials({
@@ -157,6 +178,8 @@ function createBackgroundApplication(overrides = {}) {
function start() { function start() {
srRequestHeaders.start(); srRequestHeaders.start();
startBackgroundApplication({ startBackgroundApplication({
loadOrganizationChart: message => orgChartStorage.load(message),
saveOrganizationChart: message => orgChartStorage.save(message),
requestWorkloads: message => spaWorkloads.requestWorkloads(message), requestWorkloads: message => spaWorkloads.requestWorkloads(message),
requestWorkloadForecastUpdate: message => spaWorkloads.requestWorkloadForecastUpdate(message), requestWorkloadForecastUpdate: message => spaWorkloads.requestWorkloadForecastUpdate(message),
invalidateWorkloadsCache: () => workloadsCache.invalidate(), invalidateWorkloadsCache: () => workloadsCache.invalidate(),

View File

@@ -16,7 +16,7 @@ function createBackgroundContracts() {
const OPPTY_PRODUCTS_MAX_PAGES = 250; const OPPTY_PRODUCTS_MAX_PAGES = 250;
const OPPTY_PRODUCTS_CONCURRENCY = 4; const OPPTY_PRODUCTS_CONCURRENCY = 4;
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService"; 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_SESSION_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=whitespace";
const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5; const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5;
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750; const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000; const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;

View File

@@ -1,5 +1,7 @@
/** Register synchronously; replies retain the WebExtension sendResponse contract. */ /** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({ function registerBackgroundMessages({
loadOrganizationChart,
saveOrganizationChart,
messageTypes, messageTypes,
requestWeekDetails, requestWeekDetails,
requestTimeEntries, requestTimeEntries,
@@ -42,10 +44,57 @@ function registerBackgroundMessages({
requestOpptyProducts, requestOpptyProducts,
runtimeApi runtimeApi
}) { }) {
const bookmarkPending = new Map();
async function getBookmarkFolders() {
const folders = [];
function visit(nodes, ancestors = []) {
for (const node of nodes) {
if (node.url || node.unmodifiable) continue;
const path = node.parentId ? [...ancestors, node.title] : ancestors;
if (node.parentId) folders.push({ id: node.id, label: path.join(" / ") });
visit(node.children || [], path);
}
}
visit(await runtimeApi.bookmarks.getTree());
return { ok: true, folders };
}
function addShortcutBookmark(parentId) {
if (bookmarkPending.has(parentId)) return bookmarkPending.get(parentId);
const pending = (async () => {
const { folders } = await getBookmarkFolders();
if (!folders.some(folder => folder.id === parentId)) throw new Error("Select an available bookmarks folder.");
const url = "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension";
const existing = await runtimeApi.bookmarks.search({ url });
if (existing.some(bookmark => bookmark.parentId === parentId)) return { ok: true, existing: true };
await runtimeApi.bookmarks.create({ title: "Opportunities Extension", url, parentId });
return { ok: true, existing: false };
})().finally(() => { bookmarkPending.delete(parentId); });
bookmarkPending.set(parentId, pending);
return pending;
}
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) { if (!message || !message.type) {
return false; return false;
} }
if (message.type === messageTypes.addShortcutBookmark) {
addShortcutBookmark(message.parentId).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.getBookmarkFolders) {
getBookmarkFolders().then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.getShortcutBookmarkStatus) {
runtimeApi.bookmarks.search({ url: "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension" })
.then(items => sendResponse({ ok: true, bookmarked: items.length > 0 }))
.catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.requestLookupTaskType) { if (message.type === messageTypes.requestLookupTaskType) {
requestLookupTaskType().then(sendResponse).catch(error => sendResponse({ ok: false, requestLookupTaskType().then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] })); error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
@@ -170,7 +219,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
} }
if (message.type === messageTypes.requestCurrentUser) { if (message.type === messageTypes.requestCurrentUser) {
requestCurrentUser().then(sendResponse).catch(error => sendResponse({ requestCurrentUser(sender).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load current user.", ok: false, error: error.message || "Unable to load current user.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
})); }));

View File

@@ -3,8 +3,9 @@ function createContentApplication(overrides = {}) {
document: globalThis.document, document: globalThis.document,
window: globalThis.window, window: globalThis.window,
navigator: globalThis.navigator, navigator: globalThis.navigator,
crypto: globalThis.crypto,
get localStorage() { return globalThis.localStorage; }, get localStorage() { return globalThis.localStorage; },
fetch: globalThis.fetch, fetch: globalThis.fetch && ((...args) => globalThis.fetch.call(undefined, ...args)),
browser: globalThis.browser, browser: globalThis.browser,
chrome: globalThis.chrome, chrome: globalThis.chrome,
MutationObserver: globalThis.MutationObserver, MutationObserver: globalThis.MutationObserver,
@@ -12,13 +13,15 @@ function createContentApplication(overrides = {}) {
requestAnimationFrame: globalThis.requestAnimationFrame, requestAnimationFrame: globalThis.requestAnimationFrame,
performance: globalThis.performance, performance: globalThis.performance,
cancelAnimationFrame: globalThis.cancelAnimationFrame, cancelAnimationFrame: globalThis.cancelAnimationFrame,
setTimeout: globalThis.setTimeout, setTimeout: globalThis.setTimeout && ((...args) => globalThis.setTimeout.call(undefined, ...args)),
clearTimeout: globalThis.clearTimeout, clearTimeout: globalThis.clearTimeout && ((...args) => globalThis.clearTimeout.call(undefined, ...args)),
AbortController: globalThis.AbortController,
matchMedia: globalThis.matchMedia, matchMedia: globalThis.matchMedia,
...overrides ...overrides
}; };
const messageTypes = createMessageTypes(); const messageTypes = createMessageTypes();
const currentUser = createCurrentUser({ const currentUser = createCurrentUser({
environment,
messageTypes, messageTypes,
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args) appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)
@@ -26,18 +29,38 @@ function createContentApplication(overrides = {}) {
const contracts = createContentContracts(); const contracts = createContentContracts();
const searchableSelect = createSearchableSelect({ environment }); const searchableSelect = createSearchableSelect({ environment });
const authSessionState = createAuthSessionState(contracts); const authSessionState = createAuthSessionState(contracts);
const authSalesCommonState = createAuthSalesCommonState();
const opportunitiesQueryState = createOpportunitiesQueryState(); const opportunitiesQueryState = createOpportunitiesQueryState();
const tableSorting = createTableSorting({ environment });
const orgChartIcons = createOrgChartIcons({ environment });
const accountContact = createFusionAccountContact({ environment,
getToken: () => authContentSession.requestTokenRelayOnce(),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args)
});
const orgChart = createOrgChartView({ environment, tableSorting, model: createOrgChartModel(),
requestQueryAccountContact: (...args) => accountContact.requestQueryAccountContact(...args),
requestPersistContact: (...args) => accountContact.requestPersistContact(...args),
requestOpportunitiesByContact: (...args) => accountContact.requestOpportunitiesByContact(...args),
loadLayout: loadOrganizationLayout, icon: orgChartIcons.icon, searchableSelect, modalId: contracts.MODAL_ID,
getPrincipal: () => currentUser.getUserId(), notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
async request(operation, data) {
const result = await platformContentMessages.sendRuntimeMessage({ ...data,
type: operation === "load" ? messageTypes.loadOrganizationChart : messageTypes.saveOrganizationChart });
if (!result?.ok) throw new Error(result?.error || "Unable to access local organization charts.");
return result.chart;
}
});
const opportunitiesTableState = createOpportunitiesTableState(); const opportunitiesTableState = createOpportunitiesTableState();
const opportunitiesFilterState = createOpportunitiesFilterState(contracts); const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
const preferencesState = createPreferencesState(contracts); const preferencesState = createPreferencesState(contracts);
const activityCache = createActivityCache({ authSessionState, preferencesState, environment }); const activityCache = createActivityCache({ authSessionState, preferencesState, environment });
const productsContentState = createProductsContentState(); const productsContentState = createProductsContentState();
const aiSummaryState = createAiSummaryState();
const consumptionPlansState = createConsumptionPlansState(); const consumptionPlansState = createConsumptionPlansState();
const consumptionTrendState = createConsumptionTrendState(); const consumptionTrendState = createConsumptionTrendState();
const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts); const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts);
const debugState = createDebugState(); const debugState = createDebugState(environment);
const columns = createOpportunitiesColumns({ const columns = createOpportunitiesColumns({
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args), formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args),
@@ -56,6 +79,15 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const contentModal = createContentModal({ const contentModal = createContentModal({
getShortcutBookmarkStatus: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.getShortcutBookmarkStatus }),
openBookmarkPicker: () => opportunitiesLinks.chooseBookmarkFolder(
() => platformContentMessages.sendRuntimeMessage({ type: messageTypes.getBookmarkFolders }),
async parentId => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.addShortcutBookmark, parentId });
if (result?.ok) contentModal.hideBookmarkHint();
return result;
}
),
createManageTimeButton: () => manageTime.createButton(), createManageTimeButton: () => manageTime.createButton(),
prepareSrSession: () => srSessionPreparation.ensure(), prepareSrSession: () => srSessionPreparation.ensure(),
closeActionForm: (...args) => rampProjectionView.close(...args) || manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args), closeActionForm: (...args) => rampProjectionView.close(...args) || manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
@@ -92,6 +124,7 @@ function createContentApplication(overrides = {}) {
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args), closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args), closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args),
closeServiceRequestsModal: (...args) => serviceRequestsView.closeServiceRequestsModal(...args), closeServiceRequestsModal: (...args) => serviceRequestsView.closeServiceRequestsModal(...args),
closeOrganizationChart: () => orgChart.close(),
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args), closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args), closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args), closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
@@ -136,9 +169,14 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const svgPaths = createSvgPaths({ environment }); const svgPaths = createSvgPaths({ environment });
const manageTime = createManageTime({ MODAL_ID: contracts.MODAL_ID, environment, searchableSelect, dates: createTimeDates(), async function ensureManageTimeAccess() {
await currentUser.ensure();
if (!currentUser.allowsCrmActivities()) throw new Error("Manage Time is unavailable for this profile.");
}
const manageTime = createManageTime({ allowsManageTime: () => currentUser.allowsCrmActivities(), MODAL_ID: contracts.MODAL_ID, environment, tableSorting, searchableSelect, dates: createTimeDates(),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args), notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
async requestDelete(id) { async requestDelete(id) {
await ensureManageTimeAccess();
await srSessionPreparation.ensure(); await srSessionPreparation.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesDelete, id }); const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesDelete, id });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []); debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
@@ -146,6 +184,7 @@ function createContentApplication(overrides = {}) {
return result.result; return result.result;
}, },
async requestSave(method, items) { async requestSave(method, items) {
await ensureManageTimeAccess();
await srSessionPreparation.ensure(); await srSessionPreparation.ensure();
await currentUser.ensure(); await currentUser.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesSave, method, items, emailAddress: currentUser.getUserId() }); const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesSave, method, items, emailAddress: currentUser.getUserId() });
@@ -154,6 +193,7 @@ function createContentApplication(overrides = {}) {
return result.result; return result.result;
}, },
async requestEntries(weekId) { async requestEntries(weekId) {
await ensureManageTimeAccess();
await srSessionPreparation.ensure(); await srSessionPreparation.ensure();
await currentUser.ensure(); await currentUser.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntries, weekId, emailAddress: currentUser.getUserId() }); const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntries, weekId, emailAddress: currentUser.getUserId() });
@@ -162,18 +202,21 @@ function createContentApplication(overrides = {}) {
return result.items; return result.items;
}, },
async requestTaskTypes() { async requestTaskTypes() {
await ensureManageTimeAccess();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestLookupTaskType }); const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestLookupTaskType });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []); debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load Task Types."); if (!result?.ok) throw new Error(result?.error || "Unable to load Task Types.");
return result.items; return result.items;
}, },
async requestExpand(srNumbers) { async requestExpand(srNumbers) {
await ensureManageTimeAccess();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestServiceRequestExpand, srNumbers }); const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestServiceRequestExpand, srNumbers });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []); debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load activities."); if (!result?.ok) throw new Error(result?.error || "Unable to load activities.");
return result.items; return result.items;
}, },
async requestServices(weekId) { async requestServices(weekId) {
await ensureManageTimeAccess();
await srSessionPreparation.ensure(); await srSessionPreparation.ensure();
await currentUser.ensure(); await currentUser.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestActiveServiceRequests, weekId, emailAddress: currentUser.getUserId() }); const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestActiveServiceRequests, weekId, emailAddress: currentUser.getUserId() });
@@ -182,13 +225,16 @@ function createContentApplication(overrides = {}) {
return result.items; return result.items;
}, },
async requestWeek(dateReference) { async requestWeek(dateReference) {
await ensureManageTimeAccess();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestWeekDetails, dateReference }); const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestWeekDetails, dateReference });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []); debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load week details."); if (!result?.ok) throw new Error(result?.error || "Unable to load week details.");
return result.items; return result.items;
} }
}); });
const dialogDrag = createDialogDrag({ environment });
const contentStyles = createContentStyles({ const contentStyles = createContentStyles({
enableDialogDrag: () => dialogDrag.install(),
STYLE_ID: contracts.STYLE_ID, STYLE_ID: contracts.STYLE_ID,
redwoodStyles, redwoodStyles,
environment environment
@@ -202,11 +248,6 @@ function createContentApplication(overrides = {}) {
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
updateAuthBadge: (...args) => contentModal.updateAuthBadge(...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({ const authTokenRelay = createAuthTokenRelay({
authSessionState, authSessionState,
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL, TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
@@ -231,6 +272,7 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const opportunitiesController = createOpportunitiesController({ const opportunitiesController = createOpportunitiesController({
loadServiceRequests: (token, version) => opportunityCrmServiceRequests.load(token, version),
ensureCurrentUser: () => currentUser.ensure(), ensureCurrentUser: () => currentUser.ensure(),
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args), resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); }, resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); },
@@ -254,6 +296,7 @@ function createContentApplication(overrides = {}) {
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args) setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
}); });
const fusionOpportunities = createFusionOpportunities({ const fusionOpportunities = createFusionOpportunities({
resetDebugRequests: () => debugState.reset(),
OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT, OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT,
OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL, OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL,
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
@@ -276,8 +319,8 @@ function createContentApplication(overrides = {}) {
}); });
const internalSrSession = createInternalSrSession({ const internalSrSession = createInternalSrSession({
environment, environment,
query: async srId => { query: async (srId, srNumber) => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId }); const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId, srNumber });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []); debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
return result; return result;
} }
@@ -285,7 +328,7 @@ function createContentApplication(overrides = {}) {
const crmSrPopover = createCrmSrPopover({ const crmSrPopover = createCrmSrPopover({
openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger), openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value), formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestDetails: srId => internalSrSession.request(srId), requestDetails: (srId, srNumber) => internalSrSession.request(srId, srNumber),
MODAL_ID: contracts.MODAL_ID, MODAL_ID: contracts.MODAL_ID,
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args), createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
@@ -332,6 +375,9 @@ function createContentApplication(overrides = {}) {
requestPillars: () => requestSrFormLookup(messageTypes.requestSrLookupPrimaryPillar) requestPillars: () => requestSrFormLookup(messageTypes.requestSrLookupPrimaryPillar)
}); });
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({ const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
onStateChange: () => {
if (environment.document?.getElementById?.(contracts.OPPORTUNITIES_TABLE_ID)) opportunitiesTable.renderOpportunitiesTable();
},
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value), formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
createSrButton: number => crmSrForm.createButton(number), createSrButton: number => crmSrForm.createButton(number),
closeSrForm: () => { internalSrForm.close(false); crmSrForm.close(false); }, closeSrForm: () => { internalSrForm.close(false); crmSrForm.close(false); },
@@ -344,12 +390,51 @@ function createContentApplication(overrides = {}) {
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args), requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
}); });
const opportunitiesPrint = createOpportunitiesPrint({ environment, getSnapshot: () => {
const state = opportunitiesTableState.opportunitiesTableState;
const items = opportunitiesFilters.getSortedOpportunities();
const filter = opportunitiesFilterState;
const selectedLabels = (options, selected, allLabel) => {
const matching = options.filter(option => selected.has(option.key));
if (options.length && matching.length === options.length) return allLabel;
return matching.map(option => option.label).join(", ") || "None";
};
const root = environment.document.getElementById(contracts.MODAL_ID);
const filters = [
["Reference", root?.querySelector(".opportunities-extension-form select")?.value || "-"],
["Customer", selectedLabels(opportunitiesFormatters.getCustomerOptions(), filter.selectedCustomerKeys, "All customers")],
["Owner", selectedLabels(opportunitiesFormatters.getOwnerOptions(), filter.selectedOwnerKeys, "All owners")],
["Search", filter.tableSearch || "All"],
["Status", [...filter.selectedStatuses].join(", ")],
["Stage", [...filter.selectedStages].join(", ")],
["Opportunity type", [...filter.selectedProductTypes].join(", ")]
];
for (const group of root?.querySelectorAll(".opportunities-extension-activity-filter-controls") || []) {
filters.push([group.getAttribute("aria-label").replace(/^Filter by /, ""),
[...group.querySelectorAll('[aria-pressed="true"]')].map(button => button.getAttribute("aria-label")).join("; ")]);
}
const totals = [{ label: "Total Opportunities", value: `${items.length}/${state.items.length}`, detail: "Filtered / total" }];
for (const stage of contracts.STAGE_OPTIONS) {
const stageItems = items.filter(item => item.ForecastGroup_c === stage);
const amount = stageItems.reduce((total, item) => {
const value = Number(item.PrimaryRevenue?.RevnAmount);
return total + (Number.isFinite(value) ? value : 0);
}, 0);
totals.push({ label: stage, value: opportunitiesDashboard.createStageAmountFormatter(stageItems)(amount), detail: `${stageItems.length} opportunities` });
}
return { status: state.status, filters, totals, columns,
rows: items.map(item => columns.map(column => opportunitiesFormatters.displayOpportunityValue(column, item))) };
} });
const opportunityCalendar = createOpportunityCalendar({ const opportunityCalendar = createOpportunityCalendar({
createPrintButton: () => opportunitiesPrint.createButton(),
productTypeSelection: opportunitiesFilterState.selectedProductTypes,
onProductTypeChange: () => opportunitiesTable.renderOpportunitiesTable(),
createViewIcon: view => contentIcons.createViewIcon(view), createViewIcon: view => contentIcons.createViewIcon(view),
environment, loadLibrary: () => loadOpportunityCalendarLibrary(), environment, loadLibrary: () => loadOpportunityCalendarLibrary(),
getState: () => opportunitiesTableState.opportunitiesTableState, getState: () => opportunitiesTableState.opportunitiesTableState,
getItems: () => opportunitiesFilters.getSortedOpportunities(), getItems: () => opportunitiesFilters.getSortedOpportunities(),
getVersion: () => opportunitiesQueryState.periodRequestVersion, getVersion: () => opportunitiesQueryState.periodRequestVersion,
getRange: () => opportunitiesQueryState.dateRange,
formatRevenue: item => opportunitiesFormatters.formatRevenue(item), formatRevenue: item => opportunitiesFormatters.formatRevenue(item),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value), formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
getOwnerName: item => opportunitiesFormatters.getOwnerName(item), getOwnerName: item => opportunitiesFormatters.getOwnerName(item),
@@ -362,6 +447,7 @@ function createContentApplication(overrides = {}) {
closeSrPopover: () => crmSrPopover.close() closeSrPopover: () => crmSrPopover.close()
}); });
const opportunitiesTable = createOpportunitiesTable({ const opportunitiesTable = createOpportunitiesTable({
tableSorting,
renderCalendar: (...args) => opportunityCalendar.render(...args), renderCalendar: (...args) => opportunityCalendar.render(...args),
renderActivityFilters: () => activityFilters.render(), renderActivityFilters: () => activityFilters.render(),
closeCrmSrPopover: (...args) => crmSrPopover.close(...args), closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
@@ -373,6 +459,7 @@ function createContentApplication(overrides = {}) {
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args), createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args), createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args),
createServiceRequestsButton: (...args) => serviceRequestsAction.createServiceRequestsButton(...args), createServiceRequestsButton: (...args) => serviceRequestsAction.createServiceRequestsButton(...args),
createOrganizationChartButton: (...args) => orgChart.createButton(...args),
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args), createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args), createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args), createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
@@ -402,6 +489,13 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const opportunitiesFilters = createOpportunitiesFilters({ const opportunitiesFilters = createOpportunitiesFilters({
matchesProductType: item => {
const selected = opportunitiesFilterState.selectedProductTypes;
if (selected.size === 2) return true;
const result = productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
if (!result || result.error) return false;
return selected.has(productsDetails.isBooking(result.items) ? "Booking" : "Workload");
},
matchesActivities: item => activityFilters.matches(item), matchesActivities: item => activityFilters.matches(item),
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID, CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID, CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
@@ -435,6 +529,7 @@ function createContentApplication(overrides = {}) {
}); });
const opportunitiesLinks = createOpportunitiesLinks({ const opportunitiesLinks = createOpportunitiesLinks({
MODAL_ID: contracts.MODAL_ID, MODAL_ID: contracts.MODAL_ID,
searchableSelect,
environment environment
}); });
const preferencesStorage = createPreferencesStorage({ const preferencesStorage = createPreferencesStorage({
@@ -469,7 +564,7 @@ function createContentApplication(overrides = {}) {
const refreshAll = createRefreshAll({ const refreshAll = createRefreshAll({
createRefreshIcon: () => contentIcons.createRefreshIcon(), createRefreshIcon: () => contentIcons.createRefreshIcon(),
messageTypes, activityCache, opportunitiesQueryState, productsContentState, messageTypes, activityCache, opportunitiesQueryState, productsContentState,
consumptionPlansState, consumptionTrendState, aiSummaryState, environment, consumptionPlansState, consumptionTrendState, environment,
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
reload: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args), reload: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
onFailure: error => opportunitiesTable.setOpportunitiesTableState({ status: "error", message: error.message || "Unable to refresh cached data." }), onFailure: error => opportunitiesTable.setOpportunitiesTableState({ status: "error", message: error.message || "Unable to refresh cached data." }),
@@ -490,7 +585,8 @@ function createContentApplication(overrides = {}) {
opportunitiesTable.setOpportunitiesTableState({ status: "loading", message: "Refreshing data..." }); opportunitiesTable.setOpportunitiesTableState({ status: "loading", message: "Refreshing data..." });
} }
}); });
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment }); const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment,
retrySession: () => srSessionPreparation.retry() });
const srSessionPreparation = createSrSessionPreparation({ const srSessionPreparation = createSrSessionPreparation({
async onReady() { async onReady() {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestResourceUser, emailAddress: currentUser.getUserId() }); const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestResourceUser, emailAddress: currentUser.getUserId() });
@@ -584,13 +680,12 @@ function createContentApplication(overrides = {}) {
}); });
const productsDetails = createProductsDetails({ const productsDetails = createProductsDetails({
createProjectionIndicator: (...args) => rampProjectionView.createIndicator(...args), createProjectionIndicator: (...args) => rampProjectionView.createIndicator(...args),
openProjection: (products, opportunity, trigger) => rampProjectionView.open(products[0], opportunity, trigger, products),
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args), setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
OPPORTUNITIES_COLUMNS: columns, OPPORTUNITIES_COLUMNS: columns,
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
productsContentState, productsContentState,
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args),
environment environment
}); });
const rampProjection = createRampProjection(); const rampProjection = createRampProjection();
@@ -606,6 +701,7 @@ function createContentApplication(overrides = {}) {
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender() queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
}); });
const rampProjectionView = createRampProjectionView({ const rampProjectionView = createRampProjectionView({
tableSorting,
saveEstimates: product => workloadForecastUpdate.save(product), saveEstimates: product => workloadForecastUpdate.save(product),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args), notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
MODAL_ID: contracts.MODAL_ID, MODAL_ID: contracts.MODAL_ID,
@@ -617,18 +713,9 @@ function createContentApplication(overrides = {}) {
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value), formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
environment 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 customerTableSearch = createCustomerTableSearch({ environment }); const customerTableSearch = createCustomerTableSearch({ environment });
const installBaseView = createInstallBaseView({ const installBaseView = createInstallBaseView({
tableSorting,
createSearch: (...args) => customerTableSearch.createSearch(...args), createSearch: (...args) => customerTableSearch.createSearch(...args),
MODAL_ID: contracts.MODAL_ID, MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
@@ -644,6 +731,7 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const serviceRequestsView = createServiceRequestsView({ const serviceRequestsView = createServiceRequestsView({
tableSorting,
createSearch: (...args) => customerTableSearch.createSearch(...args), createSearch: (...args) => customerTableSearch.createSearch(...args),
MODAL_ID: contracts.MODAL_ID, MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
@@ -689,6 +777,7 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const ratecardView = createRatecardView({ const ratecardView = createRatecardView({
tableSorting,
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID, ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
columns: ratecardModel.columns, columns: ratecardModel.columns,
formatValue: (...args) => ratecardModel.formatValue(...args), formatValue: (...args) => ratecardModel.formatValue(...args),
@@ -772,19 +861,18 @@ function createContentApplication(overrides = {}) {
} }
return { return {
opportunityCalendar, opportunityCalendar,
orgChart,
workloadActionForm, workloadActionForm,
activityFilters, activityFilters,
start, contracts, start, contracts,
opportunityWorkloadActions, opportunityWorkloadActions,
fusionCrmServiceRequests, opportunityCrmServiceRequests, fusionCrmServiceRequests, opportunityCrmServiceRequests,
authSessionState, authSessionState,
authSalesCommonState,
opportunitiesQueryState, opportunitiesQueryState,
opportunitiesTableState, opportunitiesTableState,
opportunitiesFilterState, opportunitiesFilterState,
preferencesState, preferencesState,
productsContentState, productsContentState,
aiSummaryState,
consumptionPlansState, consumptionPlansState,
consumptionTrendState, consumptionTrendState,
opportunitiesDashboardState, opportunitiesDashboardState,
@@ -796,7 +884,6 @@ function createContentApplication(overrides = {}) {
contentStyles, contentStyles,
authContentSession, authContentSession,
currentUser, currentUser,
authSalesCommon,
authTokenRelay, authTokenRelay,
authXsrfContent, authXsrfContent,
platformContentMessages, platformContentMessages,
@@ -821,7 +908,6 @@ function createContentApplication(overrides = {}) {
rampProjection, rampProjection,
workloadForecastUpdate, workloadForecastUpdate,
rampProjectionView, rampProjectionView,
aiSummaryContent,
consumptionPlansAction, consumptionPlansAction,
installBaseAction, installBaseAction,
installBaseView, installBaseView,

View File

@@ -0,0 +1,71 @@
function createDialogDrag({ environment }) {
const { document, window } = environment;
const panels = ".opportunities-extension-action-form-dialog, .opportunities-extension-preferences-dialog, .opportunities-extension-account-plans-dialog, .oe-org-confirm-panel";
const positions = new WeakMap();
let installed = false, drag = null;
function move(panel, dx, dy) {
const rect = panel.getBoundingClientRect();
const viewport = window.visualViewport;
const left = viewport?.offsetLeft || 0, top = viewport?.offsetTop || 0;
const width = viewport?.width || document.documentElement.clientWidth;
const height = viewport?.height || document.documentElement.clientHeight;
const x = Math.max(left - rect.width * 0.7, Math.min(rect.left + dx, left + width - rect.width * 0.3));
const y = Math.max(top - rect.height * 0.7, Math.min(rect.top + dy, top + height - rect.height * 0.3));
const previous = positions.get(panel) || { x: 0, y: 0 };
const next = { x: previous.x + x - rect.left, y: previous.y + y - rect.top };
positions.set(panel, next);
panel.style.position = "relative";
panel.style.left = `${next.x}px`;
panel.style.top = `${next.y}px`;
panel.setAttribute("data-dialog-moved", "true");
}
function stop() {
if (!drag) return;
const previous = drag;
drag = null;
previous.panel.removeAttribute("data-dialog-dragging");
if (previous.title.hasPointerCapture?.(previous.id)) previous.title.releasePointerCapture(previous.id);
}
function install() {
if (installed || window.__opportunitiesDialogDragInstalled || !document.addEventListener || !window.addEventListener) return;
installed = true;
window.__opportunitiesDialogDragInstalled = true;
document.addEventListener("pointerdown", event => {
if (event.button !== 0 || event.isPrimary === false) return;
const title = event.target.closest?.("header, h2, h3");
const panel = title?.closest(panels);
if (!panel || !panel.closest(".opportunities-extension-modal") || panel.closest("[inert], [hidden]") ||
event.target.closest("button, a, input, select, textarea, [role='button'], [contenteditable='true']")) return;
// Accept the whole title bar, but not headers inside the dialog's content.
const heading = panel.querySelector("h2, h3");
if (!heading || (heading !== title && !title.contains(heading))) return;
const rect = panel.getBoundingClientRect();
if (rect.width >= document.documentElement.clientWidth - 2 && rect.height >= document.documentElement.clientHeight - 2) return;
stop();
event.preventDefault();
drag = { title, panel, id: event.pointerId, x: event.clientX, y: event.clientY };
panel.setAttribute("data-dialog-dragging", "true");
title.setPointerCapture?.(event.pointerId);
});
document.addEventListener("pointermove", event => {
if (!drag || event.pointerId !== drag.id) return;
if (!drag.panel.isConnected || drag.panel.closest("[hidden], [inert]")) { stop(); return; }
event.preventDefault();
move(drag.panel, event.clientX - drag.x, event.clientY - drag.y);
drag.x = event.clientX; drag.y = event.clientY;
});
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) {
document.addEventListener(name, event => { if (drag?.id === event.pointerId) stop(); });
}
window.addEventListener("blur", stop);
function resize() {
stop();
for (const panel of document.querySelectorAll('[data-dialog-moved="true"]')) {
if (panel.getClientRects().length) move(panel, 0, 0);
}
}
window.addEventListener("resize", resize);
window.visualViewport?.addEventListener("resize", resize);
}
return { install };
}

View File

@@ -9,6 +9,20 @@ function createContentFusionPage({
environment environment
}) { }) {
const { Node, document, window } = environment; const { Node, document, window } = environment;
let shortcutOpened = false;
function openShortcutIfRequested() {
if (shortcutOpened || !isAllowedPage()) return;
if (window.top && window.top !== window) return;
let pending = false;
try {
const timestamp = Number(window.sessionStorage.getItem("opportunitiesExtension.shortcutIntent"));
pending = timestamp > 0 && Date.now() >= timestamp && Date.now() - timestamp < 10 * 60 * 1000;
window.sessionStorage.removeItem("opportunitiesExtension.shortcutIntent");
} catch { /* Storage may be disabled. */ }
if (!pending && window.location.hash !== "#opportunities-extension") return;
shortcutOpened = true;
openOpportunitiesModal();
}
function createOpportunitiesTile() { function createOpportunitiesTile() {
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.className = "flat-grid-cell"; wrapper.className = "flat-grid-cell";
@@ -88,6 +102,9 @@ function createContentFusionPage({
} }
function insertTile() { function insertTile() {
// Opening a bookmarked shortcut must not depend on Sales being expanded
// or its tile insertion point being present in Fusion's lazy navigation.
openShortcutIfRequested();
if (document.getElementById(TILE_ID)) { if (document.getElementById(TILE_ID)) {
return true; return true;
} }

View File

@@ -3,6 +3,7 @@ function startContentApplication({ environment, page, styles, products }) {
if (!page.isAllowedPage()) return; if (!page.isAllowedPage()) return;
styles.ensureExtensionStyles(); styles.ensureExtensionStyles();
products.ensureProductsBackgroundMessageHandler(); products.ensureProductsBackgroundMessageHandler();
environment.window?.addEventListener?.("hashchange", () => page.insertTile());
if (page.insertTile()) return; if (page.insertTile()) return;
const observer = new environment.MutationObserver(() => page.insertTile()); const observer = new environment.MutationObserver(() => page.insertTile());
observer.observe(environment.document.documentElement, { childList: true, subtree: true }); observer.observe(environment.document.documentElement, { childList: true, subtree: true });

View File

@@ -1,4 +1,6 @@
function createContentModal({ function createContentModal({
openBookmarkPicker,
getShortcutBookmarkStatus,
ACCOUNT_PLANS_MODAL_ID, ACCOUNT_PLANS_MODAL_ID,
AUTH_STATUS, AUTH_STATUS,
CUSTOMER_FILTER_BUTTON_ID, CUSTOMER_FILTER_BUTTON_ID,
@@ -28,6 +30,7 @@ function createContentModal({
closeAccountPlansModal, closeAccountPlansModal,
closeInstallBaseModal, closeInstallBaseModal,
closeServiceRequestsModal, closeServiceRequestsModal,
closeOrganizationChart,
closeActionForm, closeActionForm,
closeCustomerFilterPanel, closeCustomerFilterPanel,
closeOwnerFilterPanel, closeOwnerFilterPanel,
@@ -71,6 +74,27 @@ function createContentModal({
}) { }) {
const { document, window, setTimeout, clearTimeout, matchMedia } = environment; const { document, window, setTimeout, clearTimeout, matchMedia } = environment;
let authCompletion = null; let authCompletion = null;
let bookmarkHintTimer = null;
let bookmarkHintVersion = 0;
function hideBookmarkHint() {
bookmarkHintVersion++;
if (bookmarkHintTimer !== null) clearTimeout(bookmarkHintTimer);
bookmarkHintTimer = null;
const hint = document.getElementById("opportunities-extension-bookmark-hint");
if (hint) hint.hidden = true;
}
async function refreshBookmarkHint() {
hideBookmarkHint();
const version = bookmarkHintVersion;
try {
const result = await getShortcutBookmarkStatus?.();
const modal = document.getElementById(MODAL_ID);
const hint = document.getElementById("opportunities-extension-bookmark-hint");
if (version !== bookmarkHintVersion || !modal || modal.hidden || !hint || !result?.ok || result.bookmarked) return;
hint.hidden = false;
bookmarkHintTimer = setTimeout(hideBookmarkHint, 20000);
} catch { /* A failed bookmarks check must not interrupt opportunities. */ }
}
function resetAuthCompletion() { function resetAuthCompletion() {
if (!authCompletion) return; if (!authCompletion) return;
clearTimeout(authCompletion.waitTimer); clearTimeout(authCompletion.waitTimer);
@@ -80,12 +104,15 @@ function createContentModal({
} }
function openOpportunitiesModal() { function openOpportunitiesModal() {
ensureExtensionStyles(); ensureExtensionStyles();
const oldDebug = document.getElementById(DEBUG_PANEL_ID);
if (oldDebug) { oldDebug.hidden = true; oldDebug.textContent = ""; }
debugState.debugSecretsVisible = false; debugState.debugSecretsVisible = false;
const existingModal = document.getElementById(MODAL_ID); const existingModal = document.getElementById(MODAL_ID);
if (existingModal) { if (existingModal) {
existingModal.hidden = false; existingModal.hidden = false;
refreshBookmarkHint();
prepareSrSession?.(); prepareSrSession?.();
existingModal.querySelector("select").focus(); existingModal.querySelector("select").focus();
document.documentElement.classList.add("opportunities-extension-scroll-lock"); document.documentElement.classList.add("opportunities-extension-scroll-lock");
@@ -99,7 +126,7 @@ function createContentModal({
} }
const overlay = document.createElement("div"); const overlay = document.createElement("div");
debugState.requestLog = []; overlay.setAttribute("lang", "en");
authSessionState.tokenRelayRequest = null; authSessionState.tokenRelayRequest = null;
authSessionState.consumerTokenServiceRequest = null; authSessionState.consumerTokenServiceRequest = null;
authSessionState.accessToken = ""; authSessionState.accessToken = "";
@@ -145,7 +172,13 @@ function createContentModal({
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp"; productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
productsCacheTimestamp.setAttribute("role", "status"); productsCacheTimestamp.setAttribute("role", "status");
productsCacheTimestamp.hidden = true; productsCacheTimestamp.hidden = true;
subtitleRow.append(authBadge, productsProgressBadge, productsCacheTimestamp); const bookmarkHint = document.createElement("span");
bookmarkHint.id = "opportunities-extension-bookmark-hint";
bookmarkHint.className = "opportunities-extension-products-cache-timestamp";
bookmarkHint.setAttribute("role", "status");
bookmarkHint.textContent = "Save to favorites: Ctrl+Shift+F";
bookmarkHint.hidden = true;
subtitleRow.append(productsCacheTimestamp, bookmarkHint, authBadge, productsProgressBadge);
const titleBlock = document.createElement("div"); const titleBlock = document.createElement("div");
titleBlock.className = "opportunities-extension-title-row"; titleBlock.className = "opportunities-extension-title-row";
@@ -154,7 +187,7 @@ function createContentModal({
const closeButton = document.createElement("button"); const closeButton = document.createElement("button");
closeButton.type = "button"; closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button"; closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Fechar"); closeButton.setAttribute("aria-label", "Close");
closeButton.textContent = "X"; closeButton.textContent = "X";
closeButton.addEventListener("click", closeOpportunitiesModal); closeButton.addEventListener("click", closeOpportunitiesModal);
@@ -170,7 +203,7 @@ function createContentModal({
const preferencesButton = document.createElement("button"); const preferencesButton = document.createElement("button");
preferencesButton.type = "button"; preferencesButton.type = "button";
preferencesButton.className = "opportunities-extension-icon-button"; preferencesButton.className = "opportunities-extension-icon-button";
preferencesButton.setAttribute("aria-label", "Abrir preferências"); preferencesButton.setAttribute("aria-label", "Open preferences");
preferencesButton.title = "Preferences"; preferencesButton.title = "Preferences";
preferencesButton.append(createSettingsIcon()); preferencesButton.append(createSettingsIcon());
preferencesButton.addEventListener("click", openPreferencesModal); preferencesButton.addEventListener("click", openPreferencesModal);
@@ -277,6 +310,7 @@ function createContentModal({
opportunitiesFilterState.hasSavedCustomerFilter = true; opportunitiesFilterState.hasSavedCustomerFilter = true;
saveFilterPreferences(); saveFilterPreferences();
renderCustomerFilter(); renderCustomerFilter();
renderOwnerFilter();
renderOpportunitiesTable(); renderOpportunitiesTable();
}); });
@@ -337,6 +371,7 @@ function createContentModal({
opportunitiesFilterState.hasSavedOwnerFilter = true; opportunitiesFilterState.hasSavedOwnerFilter = true;
saveFilterPreferences(); saveFilterPreferences();
renderOwnerFilter(); renderOwnerFilter();
renderCustomerFilter();
renderOpportunitiesTable(); renderOpportunitiesTable();
}); });
@@ -402,6 +437,7 @@ function createContentModal({
const opportunitiesTable = document.createElement("table"); const opportunitiesTable = document.createElement("table");
opportunitiesTable.id = OPPORTUNITIES_TABLE_ID; opportunitiesTable.id = OPPORTUNITIES_TABLE_ID;
opportunitiesTable.className = "opportunities-extension-table"; opportunitiesTable.className = "opportunities-extension-table";
opportunitiesTable.setAttribute("data-table-layout", "standard");
opportunitiesTable.setAttribute("aria-label", "Opportunities"); opportunitiesTable.setAttribute("aria-label", "Opportunities");
tableSurface.append(opportunitiesTable); tableSurface.append(opportunitiesTable);
@@ -416,6 +452,7 @@ function createContentModal({
shell.append(header, body); shell.append(header, body);
overlay.append(shell); overlay.append(shell);
document.body.append(overlay); document.body.append(overlay);
refreshBookmarkHint();
document.documentElement.classList.add("opportunities-extension-scroll-lock"); document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler(); ensureEscapeKeyHandler();
renderStageFilterButtons(); renderStageFilterButtons();
@@ -467,6 +504,8 @@ function createContentModal({
} }
function closeOpportunitiesModal() { function closeOpportunitiesModal() {
if (closeOrganizationChart()) return;
hideBookmarkHint();
resetAuthCompletion(); resetAuthCompletion();
closeCrmSrPopover(); closeCrmSrPopover();
const modal = document.getElementById(MODAL_ID); const modal = document.getElementById(MODAL_ID);
@@ -497,14 +536,19 @@ function createContentModal({
return; return;
} }
if (event.ctrlKey && event.key.toLowerCase() === "d") { const shortcutKey = event.key.toLowerCase();
if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey &&
(shortcutKey === "d" || shortcutKey === "f")) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
toggleDebugPanel(); if (event.repeat) return;
if (shortcutKey === "d") toggleDebugPanel();
else openBookmarkPicker();
return; return;
} }
if (event.key === "Escape") { if (event.key === "Escape") {
if (closeOrganizationChart()) { event.preventDefault(); return; }
if (closeActionForm()) { if (closeActionForm()) {
event.preventDefault(); event.preventDefault();
return; return;
@@ -574,5 +618,5 @@ function createContentModal({
}); });
} }
return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler }; return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler, hideBookmarkHint, refreshBookmarkHint };
} }

View File

@@ -0,0 +1,11 @@
// Capture the bookmark before Fusion rewrites its navigation URL.
(() => {
if (window.top !== window) return;
const capture = () => {
const url = new URL(window.location.href);
if (url.hash !== "#opportunities-extension" && url.searchParams.get("opportunitiesExtension") !== "open") return;
try { window.sessionStorage.setItem("opportunitiesExtension.shortcutIntent", String(Date.now())); } catch { /* The URL remains a fallback. */ }
};
capture();
window.addEventListener("hashchange", capture);
})();

View File

@@ -1,10 +1,12 @@
function createContentStyles({ function createContentStyles({
STYLE_ID, STYLE_ID,
enableDialogDrag,
redwoodStyles, redwoodStyles,
environment environment
}) { }) {
const { document } = environment; const { document } = environment;
function ensureExtensionStyles() { function ensureExtensionStyles() {
enableDialogDrag?.();
if (document.getElementById(STYLE_ID)) { if (document.getElementById(STYLE_ID)) {
return; return;
} }

View File

@@ -0,0 +1,108 @@
/* Modal titles follow the customer Service Requests heading. Keep section and
calendar headings independent from dialog typography. */
.opportunities-extension-modal :is(.opportunities-extension-action-form-dialog,
.opportunities-extension-preferences-dialog, .opportunities-extension-account-plans-dialog,
.oe-org-confirm-panel) :is(header, h2, h3) {
cursor: grab;
touch-action: none;
user-select: none;
}
.opportunities-extension-modal [data-dialog-dragging="true"] :is(header, h2, h3) { cursor: grabbing; }
.opportunities-extension-modal #opportunities-extension-bookmark-hint {
background: #9a4600;
color: #ffffff;
border: 1px solid #b95b00;
}
.opportunities-extension-modal[data-theme="dark"] #opportunities-extension-bookmark-hint {
background: #ffca70;
color: #3b2400;
border-color: #ffd99a;
}
.opportunities-extension-modal :is(
.opportunities-extension-header,
.opportunities-extension-account-plans-header,
.opportunities-extension-preferences-header
) {
padding: 16px;
gap: 16px;
align-items: flex-start;
}
/* The main heading, status badges and toolbar share one vertical center. */
.opportunities-extension-modal .opportunities-extension-header {
align-items: center;
}
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-title-row,
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-subtitle-row,
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-header-actions {
align-items: center;
}
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-subtitle-row {
margin-block: 0;
}
/* These headers live in panels that already supply the outer inset. */
.opportunities-extension-modal :is(.oe-org-dialog.oe-contact-dialog,
.oe-org-confirm-panel, .opportunities-extension-projection-confirm,
.opportunities-extension-debug-panel) { padding: 16px; }
.opportunities-extension-modal :is(.oe-org-editor-header,
.opportunities-extension-debug-panel-header) {
padding: 0;
gap: 16px;
align-items: flex-start;
}
.opportunities-extension-modal :is(
.opportunities-extension-account-plans-header,
.opportunities-extension-preferences-header,
.opportunities-extension-debug-panel-header
) p {
margin: 5px 0 0;
font-family: "Oracle Sans", Arial, Helvetica, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 1.35;
letter-spacing: normal;
overflow-wrap: anywhere;
}
/* Search controls share the 16px inset of their table frames. */
.opportunities-extension-modal :is(.opportunities-extension-customer-table-toolbar,
.opportunities-extension-ratecard-toolbar,
.opportunities-extension-ratecard-plan-details) { padding-inline: 16px; }
.opportunities-extension-modal .opportunities-extension-customer-table-toolbar {
padding-block: 12px;
}
.opportunities-extension-modal .opportunities-extension-customer-table-search {
width: min(420px, 100%);
height: 38px;
padding: 8px 12px;
font-size: 13px;
line-height: 20px;
}
.opportunities-extension-modal :is(
.opportunities-extension-header,
.opportunities-extension-account-plans-header,
.opportunities-extension-preferences-header,
.opportunities-extension-debug-panel-header,
.oe-org-editor-header
) :is(h1, h2, h3),
.opportunities-extension-modal .opportunities-extension-projection-confirm h2,
.opportunities-extension-modal .oe-org-confirm-panel h3 {
margin: 0;
font-family: "Oracle Sans", Arial, Helvetica, sans-serif;
font-size: 20px;
font-weight: 700;
line-height: 24px;
letter-spacing: normal;
color: #312d2a;
min-width: 0;
overflow-wrap: anywhere;
}
.opportunities-extension-modal[data-theme="dark"] :is(
.opportunities-extension-header,
.opportunities-extension-account-plans-header,
.opportunities-extension-preferences-header,
.opportunities-extension-debug-panel-header,
.oe-org-editor-header
) :is(h1, h2, h3),
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-confirm h2,
.opportunities-extension-modal[data-theme="dark"] .oe-org-confirm-panel h3 {
color: #f6f4f2;
}

View File

@@ -918,51 +918,6 @@
color: #312d2a; color: #312d2a;
} }
.opportunities-extension-ai-summary {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0 0 12px;
padding: 12px 16px;
border: 1px solid #dedbd7;
border-radius: 6px;
background: #ffffff;
color: #312d2a;
font-size: 13px;
line-height: 1.5;
text-align: left;
white-space: normal;
overflow-wrap: anywhere;
}
.opportunities-extension-ai-summary strong {
display: block;
font-size: 13px;
font-weight: 600;
}
.opportunities-extension-ai-summary p {
margin: 4px 0 0;
font: inherit;
white-space: pre-wrap;
}
.opportunities-extension-ai-summary-error {
color: #b42318;
border-color: #d9928b;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary {
background: #292827;
color: #f6f4f2;
border-color: #5f5a55;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary-error {
color: #ffb4ab;
border-color: #a45c55;
}
.opportunities-extension-products-detail-heading strong { .opportunities-extension-products-detail-heading strong {
font-size: 13px; font-size: 13px;
} }

View File

@@ -0,0 +1,138 @@
/* Shared data-table presentation. Calendar grids are intentionally not data tables. */
.opportunities-extension-modal :is(
.opportunities-extension-table-surface,
.opportunities-extension-install-base-content,
.opportunities-extension-service-requests-content,
.opportunities-extension-ratecard-viewport,
.opportunities-extension-projection-viewport,
.oe-time-grid,
.oe-contact-opportunities-table
) {
box-sizing: border-box;
min-width: 0;
border: 1px solid var(--oe-table-heading-border);
border-radius: 4px;
overflow: auto;
}
/* Unpadded dialogs need an inset on the scrolling frame itself. */
.opportunities-extension-modal :is(
.opportunities-extension-install-base-content,
.opportunities-extension-service-requests-content,
.opportunities-extension-ratecard-viewport
) { margin: 16px; }
/* Other frames already sit inside a padded body: keep a single gutter. */
.opportunities-extension-modal :is(
.opportunities-extension-body,
.opportunities-extension-projection-body,
.opportunities-extension-products-detail,
.oe-time-body,
.oe-org-dialog.oe-contact-opportunities
) { padding: 16px; }
.opportunities-extension-modal table.opportunities-extension-products-table {
border: 1px solid var(--oe-table-heading-border);
}
.opportunities-extension-modal {
--oe-table-heading: #faf9f8;
--oe-table-text: #312d2a;
--oe-table-border: #ebe8e5;
--oe-table-heading-border: #b8b2ad;
--oe-table-hover: #f5f4f2;
}
.opportunities-extension-modal[data-theme="dark"] {
--oe-table-heading: #333130;
--oe-table-text: #f6f4f2;
--oe-table-border: #3f3c39;
--oe-table-heading-border: #4e4a46;
--oe-table-hover: #35322f;
}
.opportunities-extension-modal table[data-table-layout="standard"]:not(.fc-scrollgrid) {
width: 100%; border-collapse: collapse; border-spacing: 0; table-layout: auto;
color: var(--oe-table-text); font-size: 12px; line-height: 1.35;
}
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th {
padding: 10px 12px; height: 44px; vertical-align: middle; text-align: left;
white-space: nowrap; overflow-wrap: normal; word-break: normal;
font-size: 12px; font-weight: 700; color: var(--oe-table-text);
background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-heading-border);
}
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > td {
padding: 10px 12px; vertical-align: middle; text-align: left;
border-bottom: 1px solid var(--oe-table-border);
}
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr:hover { background: var(--oe-table-hover); }
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button,
.opportunities-extension-modal table[data-table-layout="standard"] .opportunities-extension-ratecard-sort {
display: flex; align-items: center; justify-content: flex-start; gap: 8px;
width: 100%; min-width: max-content; min-height: 28px; padding: 4px 18px 4px 0;
border: 0; border-radius: 0; background: transparent; color: inherit;
text-align: left; white-space: nowrap; overflow-wrap: normal; font-weight: 700;
}
.opportunities-extension-modal table[data-table-layout="standard"] .opportunities-extension-sort-label { white-space: nowrap; overflow-wrap: normal; word-break: normal; }
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button::after { position: absolute; right: 0; left: auto; font-size: 12px; }
.opportunities-extension-modal table[data-table-layout="standard"] .opportunities-extension-ratecard-sort-icon { margin-left: auto; }
/* Currency columns keep their heading and value on the same right edge. */
.opportunities-extension-modal table[data-table-layout="standard"] :is(th, td)[data-align="money"] { text-align: right; font-variant-numeric: tabular-nums; }
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th[data-align="money"] .opportunities-extension-sort-button,
.opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort { justify-content: flex-end; text-align: right; padding: 4px 0 4px 18px; }
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th[data-align="money"] .opportunities-extension-sort-button::after { left: 0; right: auto; }
.opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort-icon { order: -1; margin-left: 0; margin-right: auto; }
.opportunities-extension-modal table[data-table-layout="standard"] td[data-align="money"] > span[class*="skeleton"] { margin-left: auto; }
/* Preserve room for Overview badges and the compact daily entry controls. */
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td:is([data-column="name"], [data-column="customer"]) { padding-bottom: 10px; vertical-align: top; }
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content > .opportunities-extension-cell-actions {
position: static;
height: 24px;
flex-shrink: 0;
}
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-left: 5px; padding-right: 5px; }
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] { table-layout: fixed; }
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > th[scope="row"] { padding: 10px 12px; text-align: left; background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-border); }
/* Rate Card must fit its drawer; headings may wrap at spaces, never mid-word. */
/* Overview reserves nearly half its width for the two primary descriptions. */
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td)[data-column],
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td)[data-column] :is(a, span, button) {
font-family: "Oracle Sans", Arial, Helvetica, sans-serif;
font-size: 12px;
}
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] { table-layout: fixed; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="name"] { width: 24%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="customer"] { width: 24%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="optyNumber"] { width: 7%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="owner"] { width: 7%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="revenue"] { width: 8%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="closeDate"] { width: 7%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="status"] { width: 5%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="winProbability"] { width: 4%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="stage"] { width: 6%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="lastUpdateDate"] { width: 8%; }
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-inline: 6px; }
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button { min-width: 0; gap: 4px; }
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] .opportunities-extension-sort-label { overflow: hidden; text-overflow: ellipsis; }
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] {
min-width: 0;
table-layout: fixed;
}
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) {
padding-inline: 6px;
}
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > thead > tr > th,
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-ratecard-sort {
white-space: normal;
overflow-wrap: normal;
word-break: normal;
hyphens: none;
min-width: 0;
}
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-ratecard-sort {
padding: 4px 0;
gap: 4px;
}
.opportunities-extension-modal .opportunities-extension-ratecard-sort > span:first-child { min-width: 0; }
.opportunities-extension-modal .opportunities-extension-ratecard-sort-icon { flex: 0 0 12px; }

View File

@@ -1,103 +0,0 @@
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

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

View File

@@ -9,14 +9,14 @@ function createDebugContentLog({
name: details.name, name: details.name,
method: details.method, method: details.method,
url: details.url, url: details.url,
headers: details.headers, headers: details.headers || {},
status: "pending", status: "pending",
startedAt: new Date(), startedAt: new Date(),
completedAt: null, completedAt: null,
durationMs: null, durationMs: null,
httpStatus: null, httpStatus: null,
statusText: "", statusText: "",
requestHeaders: details.headers, requestHeaders: details.headers || {},
requestBody: details.body || "", requestBody: details.body || "",
metadata: details.metadata || {}, metadata: details.metadata || {},
responseHeaders: {}, responseHeaders: {},
@@ -26,11 +26,13 @@ function createDebugContentLog({
}; };
debugState.requestLog.push(entry); debugState.requestLog.push(entry);
debugState.persist(entry);
renderDebugPanel(); renderDebugPanel();
return entry; return entry;
} }
function logRequestSuccess(entry, response, responseBody) { function logRequestSuccess(entry, response, responseBody) {
if (!debugState.requestLog.includes(entry)) return;
entry.status = response.ok ? "success" : "failed"; entry.status = response.ok ? "success" : "failed";
entry.completedAt = new Date(); entry.completedAt = new Date();
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime(); entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
@@ -39,14 +41,17 @@ function createDebugContentLog({
entry.responseHeaders = headersToObject(response.headers); entry.responseHeaders = headersToObject(response.headers);
entry.responseBody = responseBody.value; entry.responseBody = responseBody.value;
entry.responseBodyTruncated = responseBody.truncated; entry.responseBodyTruncated = responseBody.truncated;
debugState.persist(entry);
renderDebugPanel(); renderDebugPanel();
} }
function logRequestFailure(entry, errorMessage) { function logRequestFailure(entry, errorMessage) {
if (!debugState.requestLog.includes(entry)) return;
entry.status = "failed"; entry.status = "failed";
entry.completedAt = new Date(); entry.completedAt = new Date();
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime(); entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
entry.error = errorMessage; entry.error = errorMessage;
debugState.persist(entry);
renderDebugPanel(); renderDebugPanel();
} }
@@ -81,6 +86,7 @@ function createDebugContentLog({
}; };
if (existing) Object.assign(existing, normalized); if (existing) Object.assign(existing, normalized);
else debugState.requestLog.push(normalized); else debugState.requestLog.push(normalized);
debugState.persist(existing || normalized);
}); });
renderDebugPanel(); renderDebugPanel();
} }
@@ -112,7 +118,7 @@ function createDebugContentLog({
} }
function formatHeaderBlock(headers) { function formatHeaderBlock(headers) {
const entries = Object.entries(headers); const entries = Object.entries(headers || {});
if (entries.length === 0) { if (entries.length === 0) {
return "(none)"; return "(none)";
@@ -179,14 +185,14 @@ function createDebugContentLog({
} }
function formatMetadataBlock(metadata) { function formatMetadataBlock(metadata) {
const entries = Object.entries(metadata); const entries = Object.entries(metadata || {});
if (entries.length === 0) { if (entries.length === 0) {
return "(none)"; return "(none)";
} }
return entries return entries
.map(([key, value]) => `${key}: ${value || "(none)"}`) .map(([key, value]) => `${key}: ${value ?? "(none)"}`)
.join("\n"); .join("\n");
} }

View File

@@ -11,22 +11,23 @@ function createDebugContentView({
const { document } = environment; const { document } = environment;
const categories = [ const categories = [
["all", "All"], ["authentication", "Authentication"], ["all", "All"], ["authentication", "Authentication"],
["opportunities", "Opportunities"], ["products", "Products"], ["opportunities", "Opportunities"], ["contacts", "Contacts"], ["products", "Products"],
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"], ["consumption", "Consumption"], ["installBase", "Installed Base"],
["serviceRequests", "Service Requests"], ["actions", "Actions"], ["other", "Other"] ["serviceRequests", "Service Requests"], ["actions", "Actions"], ["other", "Other"]
]; ];
let selectedCategory = "all"; let selectedCategory = "all";
const expandedIds = new Set(); const expandedIds = new Set();
let returnFocus = null; let returnFocus = null;
let generation = -1;
function getRequestCategory(entry) { function getRequestCategory(entry) {
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication"; if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
if (entry.name === "requestOpportunities") return "opportunities"; if (["requestOpportunities", "requestOpportunitiesSavedSearch", "requestOpportunitiesByContact", "resolveContactForOpportunities"].includes(entry.name)) return "opportunities";
if (["requestQueryAccountContact", "requestPersistContact"].includes(entry.name)) return "contacts";
if (entry.name === "requestOpptyProducts") return "products"; if (entry.name === "requestOpptyProducts") return "products";
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions"; if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
if (entry.name === "requestGenerateAiSummary") return "ai";
if (entry.name === "requestInstallBase") return "installBase"; if (entry.name === "requestInstallBase") return "installBase";
if (["requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests"; if (["requestSRSearchResources", "requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestResourceUser", "requestSrCatalog", "requestSrTokenCatalog", "requestPostServiceRequest"].includes(entry.name)) return "serviceRequests"; if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestResourceUser", "requestSrCatalog", "requestSrTokenCatalog", "requestPostServiceRequest"].includes(entry.name)) return "serviceRequests";
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption"; if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
return "other"; return "other";
@@ -53,7 +54,11 @@ function createDebugContentView({
closeDebugPanel(); closeDebugPanel();
return; return;
} }
debugState.reload();
returnFocus = document.activeElement; returnFocus = document.activeElement;
// Escape nested stacking contexts and inert containers created by child modals.
modal.append(panel);
panel.inert = false;
panel.hidden = false; panel.hidden = false;
renderDebugPanel(); renderDebugPanel();
panel.querySelectorAll(".opportunities-extension-debug-close-button")[0]?.focus({ preventScroll: true }); panel.querySelectorAll(".opportunities-extension-debug-close-button")[0]?.focus({ preventScroll: true });
@@ -63,6 +68,8 @@ function createDebugContentView({
const panel = document.getElementById(DEBUG_PANEL_ID); const panel = document.getElementById(DEBUG_PANEL_ID);
if (!panel) return; if (!panel) return;
panel.hidden = true; panel.hidden = true;
panel.textContent = "";
panel.onkeydown = null;
if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true }); if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
returnFocus = null; returnFocus = null;
} }
@@ -74,6 +81,7 @@ function createDebugContentView({
return; return;
} }
if (generation !== debugState.generation) { expandedIds.clear(); selectedCategory = "all"; generation = debugState.generation; }
for (const item of panel.querySelectorAll("details[data-request-id]")) { for (const item of panel.querySelectorAll("details[data-request-id]")) {
const id = item.getAttribute("data-request-id"); const id = item.getAttribute("data-request-id");
if (item.open) expandedIds.add(id); if (item.open) expandedIds.add(id);
@@ -98,7 +106,8 @@ function createDebugContentView({
title.textContent = "Debug Requests"; title.textContent = "Debug Requests";
const summary = document.createElement("p"); const summary = document.createElement("p");
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`; summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} in the current query history.`;
if (debugState.storageError) summary.textContent += ` ${debugState.storageError}`;
const running = debugState.requestLog.filter(entry => entry.status === "pending").length; const running = debugState.requestLog.filter(entry => entry.status === "pending").length;
if (running) summary.textContent += ` ${running} running.`; if (running) summary.textContent += ` ${running} running.`;
heading.append(title, summary); heading.append(title, summary);
@@ -218,7 +227,7 @@ function createDebugContentView({
const startedTime = document.createElement("time"); const startedTime = document.createElement("time");
startedTime.className = "opportunities-extension-debug-time"; startedTime.className = "opportunities-extension-debug-time";
startedTime.textContent = formatDebugTime(entry.startedAt); startedTime.textContent = formatDebugTime(entry.startedAt);
startedTime.title = `Started: ${entry.startedAt.toLocaleString()}`; startedTime.title = `Started: ${entry.startedAt.toLocaleString("en-GB")}`;
const method = document.createElement("span"); const method = document.createElement("span");
method.className = "opportunities-extension-debug-method"; method.className = "opportunities-extension-debug-method";
method.textContent = entry.method || "GET"; method.textContent = entry.method || "GET";
@@ -228,12 +237,15 @@ function createDebugContentView({
const hasDuration = typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0; const hasDuration = typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0;
duration.textContent = hasDuration duration.textContent = hasDuration
? entry.durationMs < 1000 ? `${Math.round(entry.durationMs)} ms` : `${(entry.durationMs / 1000).toFixed(2)} s` ? entry.durationMs < 1000 ? `${Math.round(entry.durationMs)} ms` : `${(entry.durationMs / 1000).toFixed(2)} s`
: entry.status === "pending" ? "In progress" : "N/D"; : entry.status === "pending" ? "In progress" : "N/A";
duration.title = hasDuration ? `Request duration: ${entry.durationMs} ms` : "Request duration not yet available"; duration.title = hasDuration ? `Request duration: ${entry.durationMs} ms` : "Request duration not yet available";
summaryMeta.append(startedTime, method, httpStatus, status, duration); summaryMeta.append(startedTime, method, httpStatus, status, duration);
heading.append(name, summaryMeta); heading.append(name, summaryMeta);
item.append(heading);
const renderDetails = () => {
if (!item.open || item.children.length > 1) return;
const lines = [ const lines = [
`${entry.method} ${entry.url}`, `${entry.method} ${entry.url}`,
`Started: ${formatDebugTime(entry.startedAt)}`, `Started: ${formatDebugTime(entry.startedAt)}`,
@@ -252,7 +264,17 @@ function createDebugContentView({
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders)); appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody, entry.name)); appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody, entry.name));
item.append(heading, details); item.append(details);
};
item.addEventListener("toggle", () => {
if (item.open) { expandedIds.add(String(entry.id)); renderDetails(); }
else {
expandedIds.delete(String(entry.id));
item.querySelector(".opportunities-extension-debug-details")?.remove();
}
});
renderDetails();
list.append(item); list.append(item);
}); });
@@ -349,7 +371,7 @@ function createDebugContentView({
} }
function formatDebugTime(date) { function formatDebugTime(date) {
return date.toLocaleTimeString(undefined, { return date.toLocaleTimeString("en-US", {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
second: "2-digit" second: "2-digit"

View File

@@ -1,6 +1,87 @@
function createDebugState() { function createDebugState(environment = {}) {
return { let storage;
requestLog: [], let tabId;
debugSecretsVisible: false try {
}; storage = environment.localStorage;
const session = environment.window?.sessionStorage;
tabId = session?.getItem("opportunities-extension-debug-tab");
if (!tabId) {
tabId = environment.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
session?.setItem("opportunities-extension-debug-tab", tabId);
}
} catch { storage = null; }
const prefix = `opportunities-extension-debug:${tabId || "memory"}:`;
let timer = null;
const pending = new Map();
const state = {
requestLog: [],
debugSecretsVisible: false,
storageError: "",
generation: 0,
persist,
flush,
reload,
reset
};
function persist(entry) {
if (!state.requestLog.includes(entry)) return;
pending.set(entry.id, entry);
try {
if (timer === null && environment.setTimeout) timer = environment.setTimeout(flush, 100);
else if (!environment.setTimeout) flush();
} catch {
// Debug persistence must never interrupt the request being recorded.
timer = null;
flush();
}
}
function flush() {
if (timer !== null) environment.clearTimeout?.(timer);
timer = null;
if (!storage) { pending.clear(); return; }
try {
// Write only changed records, never stringify all response bodies per request.
for (const [id, entry] of pending) storage.setItem(`${prefix}${id}`, JSON.stringify(entry));
storage.setItem(`${prefix}index`, JSON.stringify({ version: 1, count: state.requestLog.length }));
state.storageError = "";
} catch {
state.storageError = "Local storage is unavailable or full. Requests remain available in memory for this session.";
}
pending.clear();
}
function reload() {
flush();
if (!storage || state.storageError) return;
try {
const index = JSON.parse(storage.getItem(`${prefix}index`) || "null");
if (!index || index.version !== 1 || index.count !== state.requestLog.length) return;
const records = state.requestLog.map(entry => {
const saved = JSON.parse(storage.getItem(`${prefix}${entry.id}`) || "null");
if (!saved || saved.id !== entry.id) throw new Error("Incomplete debug record");
return { ...saved, startedAt: new Date(saved.startedAt), completedAt: saved.completedAt ? new Date(saved.completedAt) : null };
});
// Preserve the handles held by requests which are still in flight.
records.forEach((record, i) => Object.assign(state.requestLog[i], record));
} catch { state.storageError = "Saved debug data could not be read. Showing this session's requests from memory."; }
}
function reset() {
if (timer !== null) environment.clearTimeout?.(timer);
timer = null;
pending.clear();
state.requestLog = [];
state.debugSecretsVisible = false;
state.storageError = "";
state.generation++;
try {
if (!storage) return;
const keys = [];
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
if (key?.startsWith(prefix)) keys.push(key);
}
keys.forEach(key => storage.removeItem(key));
storage.setItem(`${prefix}index`, JSON.stringify({ version: 1, count: 0 }));
} catch { state.storageError = "Local storage is unavailable. Requests will be kept in memory for this session."; }
}
return state;
} }

View File

@@ -19,7 +19,7 @@
--debug-literal: #73429b; --debug-literal: #73429b;
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 50; z-index: 2147483647;
display: grid; display: grid;
grid-template-rows: auto auto minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr);
width: auto; width: auto;

View File

@@ -43,7 +43,6 @@
width: 100%; min-width: 1120px; table-layout: fixed; border-collapse: collapse; font-size: 11px; width: 100%; min-width: 1120px; table-layout: fixed; border-collapse: collapse; font-size: 11px;
} }
.opportunities-extension-install-base-table col { width: 7%; } .opportunities-extension-install-base-table col { width: 7%; }
.opportunities-extension-install-base-table .install-base-col-partyName { width: 12%; }
.opportunities-extension-install-base-table .install-base-col-productGroupName { width: 13%; } .opportunities-extension-install-base-table .install-base-col-productGroupName { width: 13%; }
.opportunities-extension-install-base-table .install-base-col-productName { width: 13%; } .opportunities-extension-install-base-table .install-base-col-productName { width: 13%; }
.opportunities-extension-install-base-table .install-base-col-quantity { width: 5%; } .opportunities-extension-install-base-table .install-base-col-quantity { width: 5%; }
@@ -78,3 +77,5 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.opportunities-extension-install-base-table .opportunities-extension-account-plans-skeleton-bar { animation: none; } .opportunities-extension-install-base-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
} }
.opportunities-extension-install-base-table .opportunities-extension-sort-button { min-height: 28px; padding: 4px 16px 4px 0; overflow-wrap: anywhere; }
.opportunities-extension-install-base-table .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }

View File

@@ -1,8 +1,8 @@
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, environment }) { function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, tableSorting, environment }) {
const { document } = environment; const { document } = environment;
let active = null; let active = null;
const columns = [ const columns = [
["partyName", "Customer"], ["productGroupName", "Product Group"], ["productName", "Product"], ["productGroupName", "Product Group"], ["productName", "Product"],
["currentSupport", "Current Support", "amount"], ["orderNumber", "Order Number"], ["currentSupport", "Current Support", "amount"], ["orderNumber", "Order Number"],
["supportStatus", "Support Status", "status"], ["quantityType", "Quantity Type"], ["supportStatus", "Support Status", "status"], ["quantityType", "Quantity Type"],
["quantity", "Quantity", "quantity"], ["supportStartDate", "Support Start Date", "date"], ["quantity", "Quantity", "quantity"], ["supportStartDate", "Support Start Date", "date"],
@@ -33,8 +33,10 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
} }
return String(value).trim(); return String(value).trim();
} }
function createTable(items, loading = false) { const sortColumns = columns.map(([key, , type]) => ({ key, type: ["amount", "quantity"].includes(type) ? "number" : type }));
function createTable(items, loading = false, sorting = tableSorting.create("installed-base", sortColumns), onSort) {
const table = element("table", "opportunities-extension-install-base-table"); const table = element("table", "opportunities-extension-install-base-table");
table.setAttribute("data-table-layout", "standard");
table.setAttribute("aria-label", "Installed base items"); table.setAttribute("aria-label", "Installed base items");
const head = element("thead"); const head = element("thead");
const header = element("tr"); const header = element("tr");
@@ -43,13 +45,14 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
const col = element("col"); const col = element("col");
col.className = `install-base-col-${key}`; col.className = `install-base-col-${key}`;
group.append(col); group.append(col);
const th = element("th", "", label); const th = element("th");
th.scope = "col"; th.scope = "col";
sorting.header(th, key, label, onSort, loading);
header.append(th); header.append(th);
}); });
head.append(header); head.append(header);
const body = element("tbody"); const body = element("tbody");
(loading ? Array.from({ length: 8 }, () => ({})) : items).forEach(item => { (loading ? Array.from({ length: 8 }, () => ({})) : sorting.select(items)).forEach(item => {
const row = element("tr"); const row = element("tr");
columns.forEach(([key, , type]) => { columns.forEach(([key, , type]) => {
const cell = element("td"); const cell = element("td");
@@ -103,6 +106,7 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
const status = element("p", "opportunities-extension-install-base-message", "Loading installed base..."); const status = element("p", "opportunities-extension-install-base-message", "Loading installed base...");
status.setAttribute("role", "status"); status.setAttribute("role", "status");
let loadedItems = [], loaded = false, failure = ""; let loadedItems = [], loaded = false, failure = "";
const sorting = tableSorting.create("installed-base", sortColumns);
const search = createSearch({ label: "Search installed base", onSearch: renderResults, const search = createSearch({ label: "Search installed base", onSearch: renderResults,
getValues: item => columns.flatMap(([key, , type]) => [item[key], formatValue(item[key], type)]) }); getValues: item => columns.flatMap(([key, , type]) => [item[key], formatValue(item[key], type)]) });
const toolbar = element("div", "opportunities-extension-customer-table-toolbar"); const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
@@ -115,7 +119,7 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
modal.addEventListener("keydown", event => { modal.addEventListener("keydown", event => {
if (event.key === "Tab") { if (event.key === "Tab") {
event.preventDefault(); event.preventDefault();
const controls = [close, search.input, content]; const controls = [close, search.input, content, ...content.querySelectorAll("button")].filter(control => !control.disabled);
const index = controls.indexOf(document.activeElement); const index = controls.indexOf(document.activeElement);
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus(); controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus();
} }
@@ -130,7 +134,10 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
: "No installed base items found for this customer."); : "No installed base items found for this customer.");
status.setAttribute("role", failure ? "alert" : "status"); status.setAttribute("role", failure ? "alert" : "status");
content.append(status); content.append(status);
if (!failure && items.length) content.append(createTable(items)); if (!failure && items.length) content.append(createTable(items, false, sorting, key => {
renderResults();
content.querySelector(`[data-sort-key="${key}"]`)?.focus();
}));
content.scrollTop = 0; content.scrollTop = 0;
} }
return { render(items, error = "") { return { render(items, error = "") {

View File

@@ -3,7 +3,7 @@
.oe-time-modal { grid-template-columns: minmax(0, 1fr); } .oe-time-modal { grid-template-columns: minmax(0, 1fr); }
.oe-time-dialog, .oe-time-body { min-width: 0; } .oe-time-dialog, .oe-time-body { min-width: 0; }
.oe-time-dialog * { box-sizing: border-box; } .oe-time-dialog * { box-sizing: border-box; }
.oe-time-dialog [hidden] { display: none !important; } .oe-time-dialog [hidden], .oe-time-launcher[hidden] { display: none !important; }
.oe-time-header { flex-shrink: 0; } .oe-time-header { flex-shrink: 0; }
.oe-time-body { padding: 18px; overflow: auto; min-height: 0; } .oe-time-body { padding: 18px; overflow: auto; min-height: 0; }
.oe-time-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; } .oe-time-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
@@ -20,6 +20,9 @@
.oe-time-grid { overflow-x: auto; } .oe-time-grid { overflow-x: auto; }
.oe-time-grid table { width: 100%; min-width: 1060px; border-collapse: collapse; table-layout: fixed; } .oe-time-grid table { width: 100%; min-width: 1060px; border-collapse: collapse; table-layout: fixed; }
.oe-time-grid th { background: var(--time-muted); padding: 10px 5px; font-weight: 600; text-align: center; border-bottom: 1px solid var(--time-border); } .oe-time-grid th { background: var(--time-muted); padding: 10px 5px; font-weight: 600; text-align: center; border-bottom: 1px solid var(--time-border); }
.oe-time-grid .opportunities-extension-sort-button { min-width: 0; min-height: 28px; padding: 4px 12px 4px 0; border: 0; border-radius: 0; gap: 0; }
.oe-time-grid .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }
.oe-time-grid .oe-time-day-label { margin-bottom: 0; }
.oe-time-grid th:first-child { width: 23%; } .oe-time-grid th:first-child { width: 23%; }
.oe-time-grid th:nth-child(2), .oe-time-grid th:nth-child(3) { width: 12%; } .oe-time-grid th:nth-child(2), .oe-time-grid th:nth-child(3) { width: 12%; }
.oe-time-grid th:nth-last-child(-n+3) { width: 46px; } .oe-time-grid th:nth-last-child(-n+3) { width: 46px; }

View File

@@ -1,4 +1,4 @@
function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requestWeek, requestSave, requestDelete, notify = () => {}, requestEntries = async () => [], requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) { function createManageTime({ allowsManageTime = () => true, MODAL_ID, environment, tableSorting, searchableSelect, dates, requestWeek, requestSave, requestDelete, notify = () => {}, requestEntries = async () => [], requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) {
const { document } = environment; const { document } = environment;
let active = null; let active = null;
let ready = false; let ready = false;
@@ -19,8 +19,8 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
catch { /* Keep the selection for this page even if persistence is blocked. */ } catch { /* Keep the selection for this page even if persistence is blocked. */ }
} }
function setReady(value) { function setReady(value) {
ready = !!value; ready = !!value && allowsManageTime();
if (launcher) { launcher.disabled = !ready; launcher.title = ready ? "Manage Time" : "Waiting for SR session ready"; } if (launcher) { launcher.hidden = !allowsManageTime(); launcher.disabled = !ready; launcher.title = ready ? "Manage Time" : "Waiting for SR session ready"; }
} }
function node(tag, className = "", text) { function node(tag, className = "", text) {
const el = document.createElement(tag); const el = document.createElement(tag);
@@ -63,7 +63,7 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
} }
function createButton() { function createButton() {
const trigger = button("Manage Time", "clock", () => open(trigger)); const trigger = button("Manage Time", "clock", () => open(trigger));
trigger.className = "opportunities-extension-icon-button"; trigger.className = "opportunities-extension-icon-button oe-time-launcher";
launcher = trigger; setReady(ready); launcher = trigger; setReady(ready);
return trigger; return trigger;
} }
@@ -77,7 +77,7 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
} }
function open(trigger) { function open(trigger) {
if (active?.saving) return; if (active?.saving) return;
if (!ready) return; if (!ready || !allowsManageTime()) return;
const host = document.getElementById(MODAL_ID); const host = document.getElementById(MODAL_ID);
if (!host || host.hidden) return; if (!host || host.hidden) return;
close(false); close(false);
@@ -114,6 +114,7 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
const taskStatus = node("div", "oe-time-status"); taskStatus.setAttribute("role", "status"); const taskStatus = node("div", "oe-time-status"); taskStatus.setAttribute("role", "status");
const taskRetry = button("Retry Task Types", "clock", loadTaskTypes, true); taskRetry.hidden = true; const taskRetry = button("Retry Task Types", "clock", loadTaskTypes, true); taskRetry.hidden = true;
const scroll = node("div", "oe-time-grid"), table = node("table"); const scroll = node("div", "oe-time-grid"), table = node("table");
table.setAttribute("data-table-layout", "standard");
table.setAttribute("aria-label", "Weekly time entries"); scroll.append(table); table.setAttribute("aria-label", "Weekly time entries"); scroll.append(table);
const footer = node("footer", "opportunities-extension-action-form-footer oe-time-footer"); const footer = node("footer", "opportunities-extension-action-form-footer oe-time-footer");
const add = button("Add", "add", () => addRow("Service"), true); const add = button("Add", "add", () => addRow("Service"), true);
@@ -142,6 +143,16 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
}); });
function blankRow(type) { return { type, hours: {}, invalidHours: {}, notes: "", srNumber: "", activity: "", taskType: "", notesOpen: false }; } function blankRow(type) { return { type, hours: {}, invalidHours: {}, notes: "", srNumber: "", activity: "", taskType: "", notesOpen: false }; }
const hourFields = ["sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours", "saturdayTaskHours"]; const hourFields = ["sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours", "saturdayTaskHours"];
const sorting = tableSorting.create("manage-time", [
{ key: "service", value: row => row.type === "Non Service" ? "Non Service" : `${entry.services.find(sr => sr.srNumber === row.srNumber)?.srTitle || ""} ${row.srNumber}` },
{ key: "activity" },
{ key: "taskType", value: row => entry.taskTypes.find(item => item.lookUpCode_c === row.taskType)?.lookUpMeaning_c || row.taskType },
{ key: "notes" },
...hourFields.map((key, weekday) => ({ key, type: "number", value: row => {
const day = entry.days.find(item => dates.fromApi(item.Date).getUTCDay() === weekday);
return day ? row.hours[day.Date] : null;
} }))
]);
function rowPayload(row) { function rowPayload(row) {
const payload = { srNumber: row.type === "Non Service" ? "non-service-sr" : row.srNumber, const payload = { srNumber: row.type === "Non Service" ? "non-service-sr" : row.srNumber,
activityCode: row.activity, taskType: row.taskType, deliveryType: row.deliveryType || "", activityCode: row.activity, taskType: row.taskType, deliveryType: row.deliveryType || "",
@@ -247,7 +258,8 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
const focusedId = document.activeElement?.id; const focusedId = document.activeElement?.id;
combos = []; combos = [];
entry.tbody.replaceChildren(); entry.tbody.replaceChildren();
entry.rows.forEach((row, index) => { sorting.select(entry.rows).forEach(row => {
const index = entry.rows.indexOf(row);
const tr = node("tr"); const tr = node("tr");
if (row.duplicateHighlightAt && Date.now() - row.duplicateHighlightAt < 5500) { if (row.duplicateHighlightAt && Date.now() - row.duplicateHighlightAt < 5500) {
tr.className = "oe-time-duplicate-row"; tr.className = "oe-time-duplicate-row";
@@ -451,15 +463,19 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
if (!entry.drafts.has(key)) entry.drafts.set(key, records.length ? rowsFromEntries(records, items) : Array.from({ length: 3 }, () => blankRow("Service"))); if (!entry.drafts.has(key)) entry.drafts.set(key, records.length ? rowsFromEntries(records, items) : Array.from({ length: 3 }, () => blankRow("Service")));
entry.rows = entry.drafts.get(key); entry.rows = entry.drafts.get(key);
const head = node("thead"), row = node("tr"); const head = node("thead"), row = node("tr");
for (const label of ["Service Request", "Activity", "Task Type"]) row.append(node("th", "", label)); for (const [key, label] of [["service", "Service Request"], ["activity", "Activity"], ["taskType", "Task Type"]]) {
const th = node("th"); sorting.header(th, key, label, renderRows); row.append(th);
}
entry.dayTotals = []; entry.dayTotals = [];
for (const day of items) { for (const day of items) {
const parsed = dates.fromApi(day.Date), th = node("th", "oe-time-day"); const parsed = dates.fromApi(day.Date), th = node("th", "oe-time-day");
const dayLabel = node("span", "oe-time-day-label", ["Su", "M", "T", "W", "Th", "F", "Sa"][parsed.getUTCDay()]); const dayButton = sorting.header(th, hourFields[parsed.getUTCDay()], ["Su", "M", "T", "W", "Th", "F", "Sa"][parsed.getUTCDay()], renderRows);
const dayLabel = dayButton.children[0]; dayLabel.className += " oe-time-day-label";
const count = node("span", "oe-time-day-total", "0"); entry.dayTotals.push(count); dayLabel.append(count); const count = node("span", "oe-time-day-total", "0"); entry.dayTotals.push(count); dayLabel.append(count);
th.append(dayLabel, node("span", "", parsed.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }))); row.append(th); th.append(node("span", "", parsed.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }))); row.append(th);
} }
for (const label of ["Notes", "Copy", ""]) row.append(node("th", "", label)); const notesHeading = node("th"); sorting.header(notesHeading, "notes", "Notes", renderRows); row.append(notesHeading);
for (const label of ["Copy", ""]) row.append(node("th", "", label));
[...row.children].forEach(th => th.setAttribute("scope", "col")); [...row.children].forEach(th => th.setAttribute("scope", "col"));
head.append(row); entry.tbody = node("tbody"); table.replaceChildren(head, entry.tbody); head.append(row); entry.tbody = node("tbody"); table.replaceChildren(head, entry.tbody);
endingValue.textContent = dates.fromApi(items[0].EndDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); renderRows(); endingValue.textContent = dates.fromApi(items[0].EndDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); renderRows();

View File

@@ -64,7 +64,10 @@ function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSum
function toggle(kind, value) { function toggle(kind, value) {
const selected = selections[kind]; const selected = selections[kind];
if (!selected || !allowsCrmActivities()) return; if (!selected || !allowsCrmActivities()) return;
if (selected.has(value)) selected.delete(value); if (selected.has("matched") && selected.has("missing")) {
selected.clear();
selected.add(value);
} else if (selected.has(value)) selected.delete(value);
else { else {
if (value === "under4") selected.clear(); if (value === "under4") selected.clear();
else selected.delete("under4"); else selected.delete("under4");

View File

@@ -1,4 +1,4 @@
function createActivityProgress({ MODAL_ID, environment }) { function createActivityProgress({ MODAL_ID, environment, retrySession = () => {} }) {
const { document, setTimeout, clearTimeout, matchMedia } = environment; const { document, setTimeout, clearTimeout, matchMedia } = environment;
const entries = new Map(); const entries = new Map();
function update(key, state, completed = 0, total = null) { function update(key, state, completed = 0, total = null) {
@@ -29,6 +29,15 @@ function createActivityProgress({ MODAL_ID, environment }) {
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`; : state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`;
if (key === "session") entry.badge.textContent = state === "complete" ? "SR session ready" : state === "error" ? "SR session unavailable" : "SR session loading"; if (key === "session") entry.badge.textContent = state === "complete" ? "SR session ready" : state === "error" ? "SR session unavailable" : "SR session loading";
entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Product workloads checked for Actions in the selected Reference, regardless of display filters"; entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Product workloads checked for Actions in the selected Reference, regardless of display filters";
if (key === "session" && state === "error") {
const retry = document.createElement("button");
retry.type = "button";
retry.className = "opportunities-extension-session-retry";
retry.textContent = "Retry";
retry.setAttribute("aria-label", "Retry Service Request session initialization");
retry.addEventListener("click", () => { retry.disabled = true; retrySession(); });
entry.badge.append(retry);
}
if (state === "complete") entry.timer = setTimeout(() => { if (state === "complete") entry.timer = setTimeout(() => {
entry.badge.setAttribute("data-fading", "true"); entry.badge.setAttribute("data-fading", "true");
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;

View File

@@ -16,6 +16,22 @@
border: 1px solid var(--fc-border-color); border-radius: 4px; font: 13px/1.4 Arial, sans-serif; border: 1px solid var(--fc-border-color); border-radius: 4px; font: 13px/1.4 Arial, sans-serif;
} }
.opportunities-calendar-grid { flex: 1 1 0; min-height: 0; min-width: 0; } .opportunities-calendar-grid { flex: 1 1 0; min-height: 0; min-width: 0; }
.opportunities-calendar-day-cards { position: absolute; inset: 27px 0 0; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; padding-bottom: 4px; }
.opportunities-calendar-day-card { display: block; width: calc(100% - 8px); padding: 0; text-align: left; font: inherit; }
.opportunities-calendar-day-card:focus-visible { outline: 2px solid #00758f; outline-offset: -2px; }
/* A single vertical sequence of months, with a separate scroll area per day. */
.opportunities-extension-calendar .fc .fc-multimonth { overflow-y: auto; overflow-x: hidden; }
.opportunities-extension-calendar .fc .fc-multimonth-month { width: 100%; }
.opportunities-extension-calendar .fc .fc-multimonth-daygrid-table { height: auto !important; }
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-body tbody { height: auto; }
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-body tr { height: var(--calendar-week-height, 190px); }
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-frame { position: relative; height: var(--calendar-week-height, 190px); min-height: var(--calendar-week-height, 190px); }
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-events {
position: absolute; inset: 27px 0 0; overflow-y: auto; overflow-x: hidden;
margin: 0; padding-bottom: 4px; scrollbar-width: thin;
}
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-event-harness { position: relative !important; top: auto !important; left: auto !important; right: auto !important; }
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-bg { pointer-events: none; }
.opportunities-extension-calendar .fc { min-width: 620px; } .opportunities-extension-calendar .fc { min-width: 620px; }
.opportunities-extension-calendar .fc .fc-toolbar { gap: 12px; flex-wrap: wrap; margin-bottom: 16px; } .opportunities-extension-calendar .fc .fc-toolbar { gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.opportunities-extension-calendar .fc .fc-toolbar-title { font-size: 18px; line-height: 26px; letter-spacing: 0; } .opportunities-extension-calendar .fc .fc-toolbar-title { font-size: 18px; line-height: 26px; letter-spacing: 0; }
@@ -103,3 +119,12 @@
.opportunities-calendar-day-count { top: 25px; } .opportunities-calendar-day-count { top: 25px; }
.fc-daygrid-week-number ~ .opportunities-calendar-day-count { left: 6px; } .fc-daygrid-week-number ~ .opportunities-calendar-day-count { left: 6px; }
} }
.opportunities-view-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--oe-table-heading-border); flex-wrap: wrap; }
.opportunities-view-bar .opportunities-extension-view-tabs { border-bottom: 0; }
.opportunities-product-type-filters { display: flex; gap: 8px; margin-left: auto; padding: 4px 0; }
.opportunities-product-type-filter { border: 1px solid #8f8a85; border-radius: 12px; padding: 3px 9px; background: transparent; color: inherit; font: 600 12px/16px Arial, sans-serif; cursor: pointer; }
.opportunities-product-type-filter[aria-pressed="true"][data-type="Booking"] { color: #714016; background: #fff0df; border-color: #c58b50; }
.opportunities-product-type-filter[aria-pressed="true"][data-type="Workload"] { color: #63358a; background: #f0e8f7; border-color: #bba5cf; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-product-type-filter[aria-pressed="true"][data-type="Booking"] { color: #ffdbb5; background: #4b321e; border-color: #c99760; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-product-type-filter[aria-pressed="true"][data-type="Workload"] { color: #ead5fc; background: #43334f; border-color: #9372ac; }
.opportunities-product-type-filter:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }

View File

@@ -1,6 +1,7 @@
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange,
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon, formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon }) { createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon,
productTypeSelection, onProductTypeChange, createPrintButton }) {
const { document } = environment; const { document } = environment;
let elements = null; let elements = null;
let calendar = null; let calendar = null;
@@ -10,34 +11,49 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
let details = null; let details = null;
let lastEvents = ""; let lastEvents = "";
let dayCounts = new Map(); let dayCounts = new Map();
let dayEvents = new Map();
const dayBadges = new Map(); const dayBadges = new Map();
let resizeObserver = null; let resizeObserver = null;
let layoutFrame = null; let layoutFrame = null;
let eventLimit = 2; let positionToday = false;
function visibleEventLimit(height, weeks, headerHeight) {
return Math.max(0, Math.min(2, Math.floor((height / Math.max(1, weeks) - headerHeight - 26) / 48)));
}
function scheduleLayout() { function scheduleLayout() {
for (const [name, icon] of [["today", "today"], ["dayGridMonth", "calendar"], ["dayGridWeek", "week"]]) {
const button = elements?.calendarHost.querySelector?.(`.fc-${name}-button`);
if (button && !button.querySelector("svg")) button.prepend(createViewIcon(icon));
}
const win = environment.window; const win = environment.window;
if (!win?.requestAnimationFrame || layoutFrame !== null) return; if (!win?.requestAnimationFrame || layoutFrame !== null) return;
layoutFrame = win.requestAnimationFrame(() => { layoutFrame = win.requestAnimationFrame(() => {
layoutFrame = null; layoutFrame = null;
if (!calendar || selectedView !== "calendar" || !elements.calendarHost.clientHeight) return; if (calendar && selectedView === "calendar") {
calendar.updateSize(); calendar.updateSize();
const frame = elements.calendarHost.querySelector(".fc-daygrid-day-frame"); if (positionToday && !elements.calendarHost.hidden) {
const events = frame?.querySelector(".fc-daygrid-day-events"); const scroller = elements.calendarHost.querySelector(".fc-multimonth");
if (!frame || !events) return; if (!scroller?.clientHeight) return;
const scroller = frame.closest(".fc-scroller"); const today = dateKey(new Date());
const weeks = elements.calendarHost.querySelectorAll(".fc-daygrid-body tr").length; const range = getRange();
if (!scroller || !weeks) return; const cell = range && today >= range.startCloseDate.slice(0, 10) && today < range.endCloseDate.slice(0, 10)
elements.calendarHost.style.setProperty("--opportunities-calendar-weeks", String(weeks)); ? elements.calendarHost.querySelector(`[data-date="${today}"]:not(.fc-day-other)`)
const headerHeight = events.getBoundingClientRect().top - frame.getBoundingClientRect().top; : null;
const limit = visibleEventLimit(scroller.clientHeight, weeks, headerHeight); if (cell) {
if (limit !== eventLimit) { eventLimit = limit; calendar.setOption("dayMaxEvents", limit); } const first = new Date(`${today}T12:00:00`);
first.setDate(first.getDate() - first.getDay() - 7);
const last = new Date(first);
last.setDate(last.getDate() + 20);
const start = dateKey(first), end = dateKey(last);
// A week crossing a month boundary has two rendered rows. Include both.
const rows = [...scroller.querySelectorAll(".fc-daygrid-body tr")].filter(row =>
[...row.querySelectorAll("[data-date]:not(.fc-day-other)")].some(day => {
const date = day.getAttribute("data-date");
return date >= start && date <= end;
}));
const headings = [...new Set(rows.map(row => row.closest(".fc-multimonth-month")?.querySelector(".fc-multimonth-header")))].filter(Boolean);
const headerSpace = headings.reduce((sum, header) => sum + header.getBoundingClientRect().height, 0);
const height = Math.max(48, Math.floor((scroller.clientHeight - headerSpace - 20) / Math.max(1, rows.length)));
elements.calendarHost.style.setProperty("--calendar-week-height", `${height}px`);
const target = rows[0] || cell;
const inset = (headings[0]?.getBoundingClientRect().height || 0) + 8;
scroller.scrollTop += target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - inset;
} else scroller.scrollTop = 0;
positionToday = false;
}
}
}); });
} }
function dateKey(date) { function dateKey(date) {
@@ -60,10 +76,25 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
const count = dayCounts.get(key) || 0; const count = dayCounts.get(key) || 0;
badge.hidden = count === 0; badge.hidden = count === 0;
badge.textContent = String(count); badge.textContent = String(count);
badge.title = `${count} oportunidade${count === 1 ? "" : "s"}`; badge.title = `${count} ${count === 1 ? "opportunity" : "opportunities"}`;
badge.setAttribute("aria-label", badge.title); badge.setAttribute("aria-label", badge.title);
badge.setAttribute("data-empty", String(count === 0)); badge.setAttribute("data-empty", String(count === 0));
} }
function renderDayCards(container, key) {
container.textContent = "";
for (const event of dayEvents.get(key) || []) {
const item = event.extendedProps.item;
const button = node("button", `opportunities-calendar-day-card fc-daygrid-event ${event.classNames.join(" ")}`);
button.type = "button";
const card = node("div", "opportunities-calendar-event-content");
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/A"} | ${formatRevenue(item)}`;
card.append(node("strong", "", event.title), node("span", "", summary));
button.title = `${event.title}\n${summary}`;
button.append(card);
button.addEventListener("click", () => showDetails(item, button));
container.append(button);
}
}
function node(tag, className, text) { function node(tag, className, text) {
const el = document.createElement(tag); const el = document.createElement(tag);
el.className = className; el.className = className;
@@ -137,7 +168,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
if (copy) identity.append(copy); if (copy) identity.append(copy);
const summary = node("dl", "opportunities-extension-action-form-context"); const summary = node("dl", "opportunities-extension-action-form-context");
for (const [label, value] of [["Customer", item.CustomerAccount?.PartyUniqueName], ["Owner", getOwnerName(item)], for (const [label, value] of [["Customer", item.CustomerAccount?.PartyUniqueName], ["Owner", getOwnerName(item)],
["Close Date", closeDate(item.EffectiveDate) ? formatDate(item.EffectiveDate) : "N/D"], ["Amount", formatRevenue(item)]]) { ["Close Date", closeDate(item.EffectiveDate) ? formatDate(item.EffectiveDate) : "N/A"], ["Amount", formatRevenue(item)]]) {
summary.append(node("dt", "", label), node("dd", "", String(value || "-"))); summary.append(node("dt", "", label), node("dd", "", String(value || "-")));
} }
const badges = node("div", "opportunities-calendar-detail-badges"); const badges = node("div", "opportunities-calendar-detail-badges");
@@ -145,42 +176,23 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
content.append(identity, summary, badges); content.append(identity, summary, badges);
showDialog(item.Name || item.OptyNumber || "Opportunity", content, item.OptyNumber ? [link, copy] : [], trigger); showDialog(item.Name || item.OptyNumber || "Opportunity", content, item.OptyNumber ? [link, copy] : [], trigger);
} }
function showDayOpportunities({ date, allSegs, jsEvent }) {
jsEvent.preventDefault();
const trigger = jsEvent.currentTarget || elements?.tabs.calendar;
const content = node("div", "opportunities-calendar-day-list");
const controls = [];
const events = new Map(allSegs.map(({ event }) => [event.id, event]));
for (const event of events.values()) {
const item = event.extendedProps.item;
const button = node("button", "opportunities-calendar-day-item"); button.type = "button";
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/D"} | ${formatRevenue(item)}`;
button.append(node("strong", "", event.title), node("span", "", summary));
button.title = `${event.title}\n${summary}`;
button.addEventListener("click", () => showDetails(item, trigger));
controls.push(button); content.append(button);
}
showDialog(`${formatDate(date.toISOString().slice(0, 10))} | ${events.size} oportunidades`, content, controls, trigger);
// A truthy non-string result prevents FullCalendar from opening its measured popover.
return true;
}
function mount(body, tableSurface) { function mount(body, tableSurface) {
resizeObserver?.disconnect(); resizeObserver?.disconnect();
if (layoutFrame !== null) environment.window?.cancelAnimationFrame(layoutFrame); if (layoutFrame !== null) environment.window?.cancelAnimationFrame(layoutFrame);
layoutFrame = null; eventLimit = 2; layoutFrame = null;
calendar?.destroy(); calendar = null; lastEvents = ""; anchoredVersion = null; selectedView = "overview"; calendar?.destroy(); calendar = null; lastEvents = ""; anchoredVersion = null; selectedView = "overview";
dayBadges.clear(); dayCounts.clear(); dayBadges.clear(); dayCounts.clear();
closeDetails(false); closeDetails(false);
const workspace = node("section", "opportunities-extension-views"); const workspace = node("section", "opportunities-extension-views");
const tablist = node("div", "opportunities-extension-view-tabs"); const tablist = node("div", "opportunities-extension-view-tabs");
tablist.setAttribute("role", "tablist"); tablist.setAttribute("aria-label", "Visualiza\u00e7\u00e3o das oportunidades"); tablist.setAttribute("role", "tablist"); tablist.setAttribute("aria-label", "Opportunity views");
const tabs = {}; const tabs = {};
const surface = node("section", "opportunities-extension-calendar"); const surface = node("section", "opportunities-extension-calendar");
const message = node("p", "opportunities-calendar-message"); message.setAttribute("role", "status"); const message = node("p", "opportunities-calendar-message"); message.setAttribute("role", "status");
const calendarHost = node("div", "opportunities-calendar-grid"); const calendarHost = node("div", "opportunities-calendar-grid");
const undated = node("details", "opportunities-calendar-undated"); const undated = node("details", "opportunities-calendar-undated");
surface.append(message, calendarHost, undated); surface.append(message, calendarHost, undated);
for (const [key, label, panel] of [["overview", "Vis\u00e3o geral", tableSurface], ["calendar", "Calend\u00e1rio", surface]]) { for (const [key, label, panel] of [["overview", "Overview", tableSurface], ["calendar", "Calendar", surface]]) {
const tab = node("button", ""); tab.type = "button"; tab.id = `opportunities-view-${key}-tab`; const tab = node("button", ""); tab.type = "button"; tab.id = `opportunities-view-${key}-tab`;
tab.append(createViewIcon(key), node("span", "", label)); tab.append(createViewIcon(key), node("span", "", label));
panel.id = `opportunities-view-${key}`; panel.setAttribute("role", "tabpanel"); panel.setAttribute("aria-labelledby", tab.id); panel.id = `opportunities-view-${key}`; panel.setAttribute("role", "tabpanel"); panel.setAttribute("aria-labelledby", tab.id);
@@ -194,7 +206,34 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
}); });
tabs[key] = tab; tablist.append(tab); tabs[key] = tab; tablist.append(tab);
} }
workspace.append(tablist, tableSurface, surface); body.append(workspace); const viewbar = node("div", "opportunities-view-bar");
viewbar.append(tablist);
if (productTypeSelection) {
const filters = node("div", "opportunities-product-type-filters");
const filterButtons = new Map();
filters.setAttribute("role", "group"); filters.setAttribute("aria-label", "Opportunity type");
for (const type of ["Booking", "Workload"]) {
const button = node("button", "opportunities-product-type-filter", type);
button.type = "button";
button.setAttribute("data-type", type);
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
button.addEventListener("click", () => {
if (["Booking", "Workload"].every(value => productTypeSelection.has(value))) {
productTypeSelection.clear();
productTypeSelection.add(type);
} else if (productTypeSelection.has(type)) productTypeSelection.delete(type); else productTypeSelection.add(type);
for (const [value, filterButton] of filterButtons) {
filterButton.setAttribute("aria-pressed", String(productTypeSelection.has(value)));
}
onProductTypeChange();
});
filterButtons.set(type, button);
filters.append(button);
}
if (createPrintButton) filters.append(createPrintButton());
viewbar.append(filters);
}
workspace.append(viewbar, tableSurface, surface); body.append(workspace);
elements = { workspace, tableSurface, surface, message, calendarHost, undated, tabs }; elements = { workspace, tableSurface, surface, message, calendarHost, undated, tabs };
if (environment.window?.ResizeObserver) { if (environment.window?.ResizeObserver) {
resizeObserver = new environment.window.ResizeObserver(scheduleLayout); resizeObserver = new environment.window.ResizeObserver(scheduleLayout);
@@ -204,6 +243,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
} }
function selectView(view) { function selectView(view) {
if (!elements || !["overview", "calendar"].includes(view)) return; if (!elements || !["overview", "calendar"].includes(view)) return;
if (view === "calendar" && selectedView !== "calendar") positionToday = true;
closeDetails(false); closeSrPopover(); selectedView = view; closeDetails(false); closeSrPopover(); selectedView = view;
elements.tableSurface.hidden = view !== "overview"; elements.surface.hidden = view !== "calendar"; elements.tableSurface.hidden = view !== "overview"; elements.surface.hidden = view !== "calendar";
for (const [key, tab] of Object.entries(elements.tabs)) { for (const [key, tab] of Object.entries(elements.tabs)) {
@@ -217,45 +257,61 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
if (!elements || selectedView !== "calendar") return; if (!elements || selectedView !== "calendar") return;
const { surface, message, calendarHost, undated } = elements; const { surface, message, calendarHost, undated } = elements;
surface.setAttribute("aria-busy", String(state.status === "loading")); surface.setAttribute("aria-busy", String(state.status === "loading"));
if (["loading", "error", "empty", "idle"].includes(state.status)) { if (["loading", "error", "idle"].includes(state.status)) {
message.textContent = state.message || (state.status === "loading" ? "Loading opportunities..." : "No opportunities found."); message.textContent = state.message || (state.status === "loading" ? "Loading opportunities..." : "No opportunities found.");
message.hidden = false; calendarHost.hidden = true; undated.hidden = true; closeDetails(false); return; message.hidden = false; calendarHost.hidden = true; undated.hidden = true; closeDetails(false); return;
} }
message.textContent = items.length ? "" : "No opportunities found."; message.hidden = !!items.length; message.textContent = items.length ? "" : "No opportunities found."; message.hidden = !!items.length;
calendarHost.hidden = false; calendarHost.hidden = false;
const events = eventsFor(items); const range = getRange();
if (!range) { calendarHost.hidden = true; return; }
const start = range.startCloseDate.slice(0, 10);
const end = range.endCloseDate.slice(0, 10);
const visibleRange = { start, end }; // Fiscal ranges end at the next period's start (exclusive).
const events = eventsFor(items).filter(event => event.start >= start && event.start < end);
dayCounts = new Map(); dayCounts = new Map();
for (const event of events) dayCounts.set(event.start, (dayCounts.get(event.start) || 0) + 1); dayEvents = new Map();
for (const { badge, key } of dayBadges.values()) updateDayBadge(badge, key); for (const event of events) {
const anchor = initialDate(state.items); dayCounts.set(event.start, (dayCounts.get(event.start) || 0) + 1);
if (!dayEvents.has(event.start)) dayEvents.set(event.start, []);
dayEvents.get(event.start).push(event);
}
for (const { badge, key, cards } of dayBadges.values()) { updateDayBadge(badge, key); renderDayCards(cards, key); }
const anchor = start;
if (!calendar) { if (!calendar) {
try { try {
library ||= loadLibrary(); library ||= loadLibrary();
calendar = new library.Calendar(calendarHost, { calendar = new library.Calendar(calendarHost, {
initialView: "dayGridMonth", initialDate: anchor, height: "100%", expandRows: true, fixedWeekCount: false, initialView: "referenceMonths", initialDate: anchor, height: "100%", fixedWeekCount: false,
views: { referenceMonths: { type: "multiMonth", visibleRange, dayMaxEvents: false, dayMaxEventRows: false } },
visibleRange, validRange: visibleRange, showNonCurrentDates: false,
multiMonthMaxColumns: 1, multiMonthMinWidth: 0,
multiMonthTitleFormat: { month: "long", year: "numeric" },
datesSet: scheduleLayout, datesSet: scheduleLayout,
locale: "pt-br", firstDay: 0, editable: false, selectable: false, eventInteractive: true, locale: "en", firstDay: 0, editable: false, selectable: false, eventInteractive: true,
headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,dayGridWeek" }, headerToolbar: false,
buttonText: { today: "Hoje", month: "M\u00eas", week: "Semana" }, dayMaxEvents: false, dayMaxEventRows: false, displayEventTime: false,
buttonHints: { prev: "Anterior", next: "Pr\u00f3ximo", today: "Hoje", dayGridMonth: "M\u00eas", dayGridWeek: "Semana" },
dayMaxEvents: 2, moreLinkText: count => `... +${count}`, displayEventTime: false,
moreLinkHint: count => `Ver mais ${count} oportunidades`,
moreLinkClick: showDayOpportunities,
weekNumbers: true, weekNumbers: true,
weekNumberContent: ({ date }) => ({ domNodes: [node("span", "opportunities-calendar-fiscal-week", fiscalWeekLabel(date))] }), weekNumberContent: ({ date }) => ({ domNodes: [node("span", "opportunities-calendar-fiscal-week", fiscalWeekLabel(date))] }),
dayCellDidMount: ({ el, date }) => { dayCellDidMount: ({ el, date }) => {
const badge = node("span", "opportunities-calendar-day-count"); const badge = node("span", "opportunities-calendar-day-count");
const key = dateKey(date); const key = dateKey(date);
updateDayBadge(badge, key); updateDayBadge(badge, key);
el.querySelector(".fc-daygrid-day-frame").append(badge); const cards = node("div", "opportunities-calendar-day-cards");
dayBadges.set(el, { badge, key }); cards.tabIndex = 0;
cards.setAttribute("role", "region");
cards.setAttribute("aria-label", `Opportunities for ${formatDate(key)}`);
renderDayCards(cards, key);
el.querySelector(".fc-daygrid-day-frame").append(badge, cards);
dayBadges.set(el, { badge, key, cards });
}, },
dayCellWillUnmount: ({ el }) => { dayBadges.get(el)?.badge.remove(); dayBadges.delete(el); }, dayCellWillUnmount: ({ el }) => { dayBadges.get(el)?.badge.remove(); dayBadges.get(el)?.cards.remove(); dayBadges.delete(el); },
eventOrder: "title", eventDisplay: "block", events, // MultiMonth imposes its own event limit. Render all cards in our scrollable day region.
eventOrder: "title", eventDisplay: "none", events,
eventContent: ({ event }) => { eventContent: ({ event }) => {
const item = event.extendedProps.item; const item = event.extendedProps.item;
const card = node("div", "opportunities-calendar-event-content"); const card = node("div", "opportunities-calendar-event-content");
const summary = node("span", "", `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/D"} | ${formatRevenue(item)}`); const summary = node("span", "", `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/A"} | ${formatRevenue(item)}`);
summary.title = summary.textContent; summary.title = summary.textContent;
card.append(node("strong", "", event.title), summary); card.append(node("strong", "", event.title), summary);
return { domNodes: [card] }; return { domNodes: [card] };
@@ -274,13 +330,21 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
} }
const signature = JSON.stringify(events); const signature = JSON.stringify(events);
calendar.batchRendering(() => { calendar.batchRendering(() => {
if (anchoredVersion !== getVersion()) { calendar.gotoDate(anchor); anchoredVersion = getVersion(); closeDetails(false); } if (anchoredVersion !== getVersion()) {
positionToday = true;
calendar.setOption("views", { referenceMonths: { type: "multiMonth", visibleRange, dayMaxEvents: false, dayMaxEventRows: false } });
calendar.setOption("visibleRange", visibleRange);
calendar.setOption("validRange", visibleRange);
calendar.gotoDate(anchor); anchoredVersion = getVersion(); closeDetails(false);
const scroller = calendarHost.querySelector?.(".fc-multimonth");
if (scroller) scroller.scrollTop = 0;
}
if (signature !== lastEvents) { calendar.removeAllEvents(); calendar.addEventSource(events); lastEvents = signature; closeDetails(false); } if (signature !== lastEvents) { calendar.removeAllEvents(); calendar.addEventSource(events); lastEvents = signature; closeDetails(false); }
}); });
const missing = items.filter(item => !closeDate(item.EffectiveDate)); const missing = items.filter(item => !closeDate(item.EffectiveDate));
undated.hidden = !missing.length; undated.textContent = ""; undated.hidden = !missing.length; undated.textContent = "";
if (missing.length) { if (missing.length) {
undated.append(node("summary", "", `Sem Close Date v\u00e1lido (${missing.length})`)); undated.append(node("summary", "", `No valid Close Date (${missing.length})`));
for (const item of missing) { for (const item of missing) {
const button = node("button", "", `${item.OptyNumber || "-"} | ${item.Name || "-"}`); button.type = "button"; const button = node("button", "", `${item.OptyNumber || "-"} | ${item.Name || "-"}`); button.type = "button";
button.addEventListener("click", () => showDetails(item, button)); undated.append(button); button.addEventListener("click", () => showDetails(item, button)); undated.append(button);
@@ -288,5 +352,5 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
} }
scheduleLayout(); scheduleLayout();
} }
return { mount, render, selectView, closeDetails, closeDate, eventsFor, initialDate, fiscalWeekLabel, visibleEventLimit }; return { mount, render, selectView, closeDetails, closeDate, eventsFor, initialDate, fiscalWeekLabel };
} }

View File

@@ -2,12 +2,12 @@ function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, f
return [ return [
{ key: "name", label: "Name", value: (item) => item.Name, display: (item) => item.Name }, { 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: "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: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyName"]) },
{ key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName }, { key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName },
{ key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue }, { 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: "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: "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: "winProbability", label: "Prob.", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
{ key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c }, { 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: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }
]; ];

View File

@@ -13,6 +13,7 @@ function createOpportunitiesController({
productsContentState, productsContentState,
renderProductsProgress, renderProductsProgress,
requestOpportunitiesPage, requestOpportunitiesPage,
loadServiceRequests = async () => {},
resetCrmServiceRequests, resetCrmServiceRequests,
resetWorkloadActions, resetWorkloadActions,
resetWorkloads, resetWorkloads,
@@ -23,6 +24,7 @@ function createOpportunitiesController({
}) { }) {
async function requestOpportunitiesForPeriod(period) { async function requestOpportunitiesForPeriod(period) {
const dateRange = getFiscalPeriodRange(period, new Date()); const dateRange = getFiscalPeriodRange(period, new Date());
opportunitiesQueryState.dateRange = dateRange;
const requestVersion = ++opportunitiesQueryState.periodRequestVersion; const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
resetWorkloadActions(); resetWorkloadActions();
resetWorkloads(); resetWorkloads();
@@ -94,6 +96,9 @@ function createOpportunitiesController({
status: "ready", status: "ready",
message: "" message: ""
}); });
// Start SR only after all opportunity pages have been displayed.
// Its incremental updates run independently of the opportunity request.
try { Promise.resolve(loadServiceRequests(token, requestVersion)).catch(() => {}); } catch { /* Keep the displayed opportunities available. */ }
requestOpptyProductsInBackground(allItems, token); requestOpptyProductsInBackground(allItems, token);
} }
} catch (error) { } catch (error) {

View File

@@ -1,4 +1,4 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, formatDate, environment }) { function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, formatDate, environment, onStateChange = () => {} }) {
let status = "idle"; let status = "idle";
let byOpportunity = new Map(); let byOpportunity = new Map();
let pending = null; let pending = null;
@@ -20,6 +20,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
async function run(token, version, currentGeneration, cacheOptions) { async function run(token, version, currentGeneration, cacheOptions) {
const isActive = () => currentGeneration === generation; const isActive = () => currentGeneration === generation;
status = "loading"; status = "loading";
onStateChange();
try { try {
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null; const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
if (!isActive()) return false; if (!isActive()) return false;
@@ -27,12 +28,28 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
let items = cached?.items; let items = cached?.items;
if (!items) { if (!items) {
reportProgress("running", 0); reportProgress("running", 0);
items = await requestCrmServiceRequest(token, version, isActive, (count, total) => { items = await requestCrmServiceRequest(token, version, isActive, (count, total, partialItems) => {
if (isActive()) reportProgress("running", count, total); if (!isActive()) return;
reportProgress("running", count, total);
if (Array.isArray(partialItems)) {
updateItems(partialItems);
onStateChange();
}
}); });
if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items); if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items);
} }
if (!items || !isActive()) return false; if (!items || !isActive()) return false;
updateItems(items);
status = "ready";
if (!cached) reportProgress("complete", items.length, items.length);
onStateChange();
return true;
} catch {
if (isActive()) { status = "error"; reportProgress("error"); onStateChange(); }
return false;
}
}
function updateItems(items) {
const grouped = new Map(); const grouped = new Map();
for (const item of items) { for (const item of items) {
const number = item?.OpportunityName_Id_c?.OptyNumber; const number = item?.OpportunityName_Id_c?.OptyNumber;
@@ -43,13 +60,6 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
} }
byOpportunity = grouped; byOpportunity = grouped;
loadedItems = items; loadedItems = items;
status = "ready";
if (!cached) reportProgress("complete", items.length, items.length);
return true;
} catch {
if (isActive()) { status = "error"; reportProgress("error"); }
return false;
}
} }
function getSummary(optyNumber) { function getSummary(optyNumber) {
if (!allowsCrmActivities() || !optyNumber) return null; if (!allowsCrmActivities() || !optyNumber) return null;
@@ -58,39 +68,40 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
(typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isFinite(Number(value)) (typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isFinite(Number(value))
).map(Number); ).map(Number);
return { return {
state: status === "ready" ? items.length ? "matched" : "missing" : status, state: status === "loading" && items.length ? "matched" : status === "ready" ? items.length ? "matched" : "missing" : status,
reportedHours: hours.length === items.length ? hours.reduce((total, value) => total + value, 0) : null, reportedHours: hours.length === items.length ? hours.reduce((total, value) => total + value, 0) : null,
hasUnderFourHours: status === "ready" && hours.some(value => value < 4) hasUnderFourHours: (status === "ready" || status === "loading") && hours.some(value => value < 4)
}; };
} }
function createBadge(optyNumber) { function createBadge(optyNumber) {
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null; const badgeStatus = status === "loading" && byOpportunity.get(String(optyNumber))?.length ? "ready" : status;
if (!allowsCrmActivities() || !optyNumber || badgeStatus === "idle" || badgeStatus === "loading") return null;
const items = byOpportunity.get(String(optyNumber)) || []; const items = byOpportunity.get(String(optyNumber)) || [];
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span"); const badge = environment.document.createElement(items.length && badgeStatus === "ready" ? "button" : "span");
badge.className = "opportunities-extension-crm-sr-badge"; badge.className = "opportunities-extension-crm-sr-badge";
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing"); badge.setAttribute("data-state", badgeStatus === "error" ? "error" : items.length ? "matched" : "missing");
const summary = getSummary(optyNumber); const summary = getSummary(optyNumber);
const reportedHours = summary.reportedHours !== null const reportedHours = summary.reportedHours !== null
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours) ? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
: "N/D"; : "N/A";
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/D" ? "" : "h"}` : "0h"); setBadgeContent(badge, "sr", badgeStatus === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/A" ? "" : "h"}` : "0h");
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value); const display = value => value === null || value === undefined || value === "" ? "N/A" : String(value);
const descriptions = items.map(item => [ const descriptions = items.map(item => [
`Title: ${display(item.Title)}`, `Title: ${display(item.Title)}`,
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`, `Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
`Reported Hours: ${display(item.ReportedHours_c)}`, `Reported Hours: ${display(item.ReportedHours_c)}`,
`Customer Deadline: ${display(formatDate(item.CustomerDeadline_c))}`, `Customer Deadline: ${display(formatDate(item.CustomerDeadline_c))}`,
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/D"}`, `Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/A"}`,
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`, `Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}` `Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
].join("\n")); ].join("\n"));
const description = status === "error" ? "Unable to check CRM service requests. See Debug Requests." const description = badgeStatus === "error" ? "Unable to check CRM service requests. See Debug Requests."
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team." : !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
: items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n"); : items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
badge.setAttribute("aria-label", description); badge.setAttribute("aria-label", description);
badge.title = "Whether a Service Request has been created for the opportunities."; badge.title = "Whether a Service Request has been created for the opportunities.";
if (items.length && status === "ready") bindPopover(badge, items, descriptions); if (items.length && badgeStatus === "ready") bindPopover(badge, items, descriptions);
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber))); if (!items.length && badgeStatus === "ready") badge.append(createSrButton(String(optyNumber)));
badge.tabIndex = 0; badge.tabIndex = 0;
return badge; return badge;
} }

Some files were not shown because too many files have changed in this diff Show More